batchCooking/apps/web/cypress/e2e/recipes.cy.ts
Nicolas eaabd78d28 test(web): agrandit le viewport du test de détail recette
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.
2026-08-19 10:37:44 +02:00

249 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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",
);
});
});