Dernier morceau de la même régression : la grille catalogue+panneau (master-detail) est un layout desktop, comme la grille de planning (voir planning-page.cy.ts's `cy.viewport(1600, 900)`, même justification). Au viewport par défaut de Cypress (1000×660), le panneau de détail n'a tout simplement pas assez de hauteur pour afficher photo + en-tête + ingrédients + étapes sans avoir besoin de scroller en interne — c'est le comportement voulu de `.recipe-detail-panel` (`overflow-y: auto`, voir son commentaire dans recipes.scss), pas un bug. Ce n'était jamais visible avant que `.app-layout` ait une vraie hauteur bornée (voir les 2 commits précédents) : tout grandissait sans limite, donc rien n'avait jamais besoin de scroller pour être visible. Reproduit en rejouant le flow exact du test dans le navigateur (visite /recettes, clic sur la ligne Omelette, pas juste une navigation directe vers /recettes/2 — la simple navigation directe ne reproduisait pas l'échec) : à 1000×660 la description était bien coupée (descWithinPanel: false) ; à 1600×900 tout rentre.
249 lines
9.1 KiB
TypeScript
249 lines
9.1 KiB
TypeScript
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
|
||
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
|
||
// real API behavior against a real database).
|
||
|
||
const authenticatedProfile = {
|
||
id: 1,
|
||
firstName: "Alice",
|
||
lastName: "Martin",
|
||
email: "alice@example.com",
|
||
tokenVersion: 0,
|
||
houseId: 1,
|
||
dietId: null,
|
||
};
|
||
|
||
const vegetarien = { id: 1, key: "vegetarian" };
|
||
const gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" };
|
||
const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" };
|
||
|
||
const ratatouille = {
|
||
id: 1,
|
||
name: "Ratatouille",
|
||
description: null,
|
||
picture: null,
|
||
authorId: 1,
|
||
visibility: "PERSONAL",
|
||
allergens: [],
|
||
diets: [vegetarien],
|
||
isFavorite: true,
|
||
};
|
||
|
||
const omelette = {
|
||
id: 2,
|
||
name: "Omelette",
|
||
description: null,
|
||
picture: null,
|
||
authorId: 1,
|
||
visibility: "PERSONAL",
|
||
allergens: [oeufs],
|
||
diets: [],
|
||
isFavorite: false,
|
||
};
|
||
|
||
const omeletteDetail = {
|
||
...omelette,
|
||
description: "Une omelette toute simple.",
|
||
ingredients: [
|
||
{
|
||
ingredient: {
|
||
id: 10,
|
||
key: "egg",
|
||
icon: "EGG",
|
||
category: "CREMERIE_FROMAGE",
|
||
subcategory: "OEUFS",
|
||
allergens: [oeufs],
|
||
diets: [],
|
||
},
|
||
quantity: 3,
|
||
unit: "unité",
|
||
},
|
||
],
|
||
steps: [
|
||
{ id: 1, description: "Battre les œufs.", picture: null, order: 1 },
|
||
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2 },
|
||
],
|
||
};
|
||
|
||
function interceptAuth() {
|
||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
||
}
|
||
|
||
describe("Recipe catalog", () => {
|
||
beforeEach(() => {
|
||
interceptAuth();
|
||
});
|
||
|
||
it("defaults to the Favoris tab and lists its recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
expect(req.url).to.include("tab=favoris");
|
||
req.reply({ statusCode: 200, body: [ratatouille] });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
|
||
cy.contains("h1", "Recettes").should("be.visible");
|
||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Favoris");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
// The favorited row carries the ★ fav-mark.
|
||
cy.contains(".recipe-table__name", "Ratatouille")
|
||
.find(".recipe-table__fav-mark")
|
||
.should("exist");
|
||
cy.contains(".recipe-table__name", "Ratatouille")
|
||
.parents("tr")
|
||
.find(".diet-badge")
|
||
.should("contain.text", "Végétarien");
|
||
});
|
||
|
||
it("shows the empty state when a tab has no recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains("Aucune recette pour le moment.").should("be.visible");
|
||
});
|
||
|
||
it("shows an error state when the catalog fails to load", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains(".recipes-page__status--error", "Impossible de charger").should("be.visible");
|
||
});
|
||
|
||
it("switches tabs, re-fetching each one's own recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
const tab = new URL(req.url).searchParams.get("tab");
|
||
const body = tab === "perso" ? [omelette] : [ratatouille];
|
||
req.reply({ statusCode: 200, body });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
|
||
// Not asserting the specific request URL here — React StrictMode (see
|
||
// main.tsx) double-invokes mount/update effects in dev, so this can
|
||
// legitimately fire twice; the rendered result converges either way
|
||
// (same reasoning as planning-page.cy.ts's week-navigation tests).
|
||
cy.contains(".recipe-tabs__tab", "Perso").click();
|
||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
|
||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
||
|
||
// The disabled "Sources (bientôt)" placeholder never becomes active.
|
||
cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled");
|
||
});
|
||
|
||
it("searches within the active tab, debounced", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
const search = new URL(req.url).searchParams.get("search");
|
||
req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
|
||
// Same "assert the rendered result, not the request count/URL" reasoning
|
||
// as the tab-switch test above — the 300ms debounce plus StrictMode's
|
||
// double-invoked effects make the exact number/order of requests an
|
||
// implementation detail, not something worth pinning down here.
|
||
cy.get(".recipes-page__search").type("Omel");
|
||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
||
});
|
||
|
||
it("opens a recipe's detail alongside the table when its row is selected", () => {
|
||
// Desktop-only master-detail layout, same reasoning as
|
||
// planning-page.cy.ts's "Planning grid" tests — wider/taller than
|
||
// Cypress's default 1000×660, which doesn't leave the detail panel
|
||
// (photo + header + ingredients + steps) enough height to show
|
||
// everything without needing its own internal scroll (by design, see
|
||
// `.recipe-detail-panel` in recipes.scss) — this test asserts full
|
||
// visibility without scrolling, so it needs the room.
|
||
cy.viewport(1600, 900);
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
|
||
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe");
|
||
|
||
cy.visit("/recettes");
|
||
cy.contains(".recipe-table__name", "Omelette").click();
|
||
cy.wait("@getRecipe");
|
||
|
||
cy.url().should("include", "/recettes/2");
|
||
// The table stays mounted (master-detail, not a page navigation) —
|
||
// both rows are still visible next to the detail panel.
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
cy.get("tr.selected .recipe-table__name").should("contain.text", "Omelette");
|
||
|
||
cy.get(".recipe-detail-panel").within(() => {
|
||
cy.contains("h2", "Omelette").should("be.visible");
|
||
cy.contains("Une omelette toute simple.").should("be.visible");
|
||
cy.contains("Battre les œufs.").should("be.visible");
|
||
cy.contains("Cuire à la poêle.").should("be.visible");
|
||
});
|
||
});
|
||
|
||
it("shows a not-found message for a selected id the API rejects", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
cy.intercept("GET", "**/recipes/999", {
|
||
statusCode: 404,
|
||
// ErrorCode.RECIPE_NOT_FOUND (packages/shared/src/errors/error-codes.ts)
|
||
// — RecipeDetailPanel only renders the "not found" message for this
|
||
// exact code, anything else falls into its generic error state.
|
||
body: { code: 4045, message: "not found" },
|
||
});
|
||
|
||
cy.visit("/recettes/999");
|
||
|
||
cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible");
|
||
});
|
||
|
||
it("toggles a recipe's favorite from the detail panel and reflects it in the table", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] });
|
||
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail });
|
||
cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite");
|
||
|
||
cy.visit("/recettes/2");
|
||
|
||
cy.contains(".recipe-table__name", "Omelette")
|
||
.find(".recipe-table__fav-mark")
|
||
.should("not.exist");
|
||
|
||
cy.get(".favorite-star-button").click();
|
||
cy.wait("@favorite");
|
||
|
||
cy.get(".favorite-star-button").should("have.class", "is-favorite");
|
||
cy.contains(".recipe-table__name", "Omelette").find(".recipe-table__fav-mark").should("exist");
|
||
});
|
||
|
||
it("deletes a recipe after a two-step confirmation, then clears the selection", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] });
|
||
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail });
|
||
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
||
|
||
cy.visit("/recettes/2");
|
||
cy.contains(".recipe-detail-panel", "Omelette").should("be.visible");
|
||
|
||
cy.contains(".recipe-detail-panel__danger-button", "Supprimer").click();
|
||
cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click();
|
||
cy.wait("@deleteRecipe");
|
||
|
||
cy.url().should("match", /\/recettes\/?$/);
|
||
cy.contains(".recipe-table__name", "Omelette").should("not.exist");
|
||
cy.contains("Sélectionnez une recette dans le tableau").should("be.visible");
|
||
});
|
||
|
||
it("links the new-recipe button to the recipe form", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains(".recipes-page__new-button", "Nouvelle recette").should(
|
||
"have.attr",
|
||
"href",
|
||
"/recettes/nouvelle",
|
||
);
|
||
});
|
||
});
|