// Mocks the API via cy.intercept — this job doesn't run a live backend (see // .github/workflows/ci.yml); apps/api's own Mocha suite covers real // `GET /cooking-session` behavior (including the optimizer) against a real // database. const authenticatedProfile = { id: 1, firstName: "Alice", lastName: "Martin", email: "alice@example.com", tokenVersion: 0, houseId: 1, dietId: null, }; // 2026-08-17 is a Monday — frozen so "this week" is deterministic. const TODAY = new Date("2026-08-17T09:00:00Z"); /** Bare `IngredientView` — only `key` drives the page's label lookup. */ function ingredient(key: string) { return { id: 1, key, icon: "VEGETABLE", category: "freshProduce", subcategory: "vegetables", reproducible: false, allergens: [], diets: [], }; } /** A 3-phase plan: a pooled prep task in mise-en-place, then two cooking phases, the last with a passive cook floated into its background. */ function planFixture() { return { startDate: "2026-08-17T00:00:00.000Z", finishDate: "2026-08-23T00:00:00.000Z", recipes: [ { recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 }, ], phases: [ { index: 0, kind: "mise-en-place", background: [], notes: [], tasks: [ { id: "prep:chop:onion", kind: "merged-prep", technique: { id: 1, key: "chop" }, description: null, ingredients: [ { ingredient: ingredient("onion"), quantity: 5, unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 }, }, ], utensils: [{ id: 1, key: "knife" }], temperatures: [], sourceRecipes: [ { recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 }, ], originalSteps: [], }, ], }, { index: 1, kind: "cooking", background: [], notes: [], tasks: [ { id: "step:0:1", kind: "step", technique: { id: 5, key: "simmer" }, description: "Faire mijoter le bouillon", ingredients: [], utensils: [], temperatures: [], sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }], originalSteps: [], }, ], }, { index: 2, kind: "cooking", background: [ { id: "bg:step:0:1", technique: { id: 5, key: "simmer" }, description: "Faire mijoter le bouillon", recipeId: 1, recipeName: "Soupe à l'oignon", }, ], notes: [], tasks: [ { id: "step:1:3", kind: "step", technique: { id: 4, key: "bake" }, description: "Enfourner la tarte", ingredients: [], utensils: [], temperatures: [], sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }], originalSteps: [], }, ], }, ], }; } /** * A one-phase plan whose mise-en-place has a pooled `merged-setup` preheat * (both recipes, same 180 °C) plus two un-merged preheats at 200 °C / 220 °C * that trigger an `appliance-temperature-conflict` note. */ function setupPlanFixture() { const soupe = { recipeId: 1, name: "Soupe", portions: 4 }; const gratin = { recipeId: 2, name: "Gratin", portions: 4 }; const roti = { recipeId: 3, name: "Rôti", portions: 4 }; return { startDate: "2026-08-17T00:00:00.000Z", finishDate: "2026-08-23T00:00:00.000Z", recipes: [soupe, gratin, roti], phases: [ { index: 0, kind: "mise-en-place", background: [], notes: [ { kind: "appliance-temperature-conflict", technique: { id: 9, key: "preheat" }, temperatures: [ { celsius: 200, gasMark: null, qualitative: null }, { celsius: 220, gasMark: null, qualitative: null }, ], }, ], tasks: [ { id: "setup:preheat:c180", kind: "merged-setup", technique: { id: 9, key: "preheat" }, description: null, ingredients: [], utensils: [], temperatures: [{ celsius: 180, gasMark: null, qualitative: null }], sourceRecipes: [soupe, gratin], originalSteps: [], }, { id: "step:1:10", kind: "step", technique: { id: 9, key: "preheat" }, description: "Préchauffer le four à 200°C", ingredients: [], utensils: [], temperatures: [{ celsius: 200, gasMark: null, qualitative: null }], sourceRecipes: [gratin], originalSteps: [], }, { id: "step:2:20", kind: "step", technique: { id: 9, key: "preheat" }, description: "Préchauffer le four à 220°C", ingredients: [], utensils: [], temperatures: [{ celsius: 220, gasMark: null, qualitative: null }], sourceRecipes: [roti], originalSteps: [], }, ], }, ], }; } /** * Minimal `RecipeView` detail for one of `planFixture()`'s recipes — just * enough (name/portions/one step) to prove the right recipe's own detail * rendered inline, not a full catalog-style fixture. */ function recipeDetail(id: number, name: string, portions: number, stepDescription: string) { return { id, name, description: null, picture: null, portions, authorId: 1, visibility: "PERSONAL", allergens: [], diets: [], isFavorite: false, ingredients: [], steps: [{ id: 1, description: stepDescription, picture: null, order: 1, techSteps: [] }], }; } describe("Cooking session page", () => { beforeEach(() => { cy.viewport(1400, 900); cy.clock(TODAY, ["Date"]); cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); }); it("shows the empty message when nothing is planned that week", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: { startDate: "2026-08-17T00:00:00.000Z", finishDate: "2026-08-23T00:00:00.000Z", recipes: [], phases: [], }, }); cy.visit("/cuisiner"); cy.contains("h1", "Cuisiner cette semaine").should("be.visible"); cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible"); }); it("opens on the Plan tab: a stepper of every phase, only the first phase's tasks shown", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as( "getPlan", ); cy.visit("/cuisiner?date=2026-08-17"); cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17"); // One stepper node per phase; the first is the current one. cy.get(".cooking-stepper__node").should("have.length", 3); cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--current"); cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible"); cy.contains(".cooking-plan__count", "0 / 1 tâches").should("be.visible"); // Mise en place: the pooled prep task, flagged shared, naming both recipes. cy.get(".cooking-task--merged-prep") .should("contain.text", "Hacher") .and("contain.text", "Oignon") .and("contain.text", "Mutualisé"); // It sits in one mini section headed with both recipes' names. cy.get(".cooking-phase__recipe-group-title").should( "have.text", "Soupe à l'oignon, Tarte à l'oignon", ); // The next phase's step is not rendered yet. cy.contains("Faire mijoter le bouillon").should("not.exist"); // Prev is disabled on the first phase. cy.contains(".cooking-plan__nav-btn", "Étape précédente").should("be.disabled"); }); it("walks the phases with the next/prev buttons and by clicking a stepper node", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }); cy.visit("/cuisiner?date=2026-08-17"); // Forward to phase 2. cy.contains(".cooking-plan__nav-btn", "Étape suivante").click(); cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible"); cy.contains(".cooking-phase__tasks", "Faire mijoter le bouillon").should("be.visible"); // Forward to phase 3 — its background band shows the soup still simmering. cy.contains(".cooking-plan__nav-btn", "Étape suivante").click(); cy.contains(".cooking-plan__position", "Étape 3 / 3").should("be.visible"); cy.contains(".cooking-phase__background", "Pendant ce temps") .should("contain.text", "Faire mijoter le bouillon") .and("contain.text", "Soupe à l'oignon"); cy.contains(".cooking-phase__tasks", "Enfourner la tarte").should("be.visible"); cy.contains(".cooking-plan__nav-btn", "Étape suivante").should("be.disabled"); // Back one step. cy.contains(".cooking-plan__nav-btn", "Étape précédente").click(); cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible"); // Jump straight back to the first phase from the stepper. cy.get(".cooking-stepper__node").first().click(); cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible"); cy.get(".cooking-task--merged-prep").should("be.visible"); }); it("ticks a task, marks its phase done in the stepper, and keeps the tick after a reload", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }); cy.visit("/cuisiner?date=2026-08-17"); cy.get(".cooking-task").first().find('input[type="checkbox"]').check({ force: true }); cy.get(".cooking-task").first().should("have.class", "cooking-task--done"); // The whole phase is now done — its stepper node reflects that. cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done"); cy.contains(".cooking-plan__count", "1 / 1 tâches").should("be.visible"); cy.reload(); cy.get(".cooking-task").first().should("have.class", "cooking-task--done"); cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done"); }); it("lists the week's recipes on the Recettes tab, each showing its portions", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }); cy.visit("/cuisiner?date=2026-08-17"); cy.contains(".cooking-session-page__tab", "Recettes").click(); cy.get(".cooking-recipes__item").should("have.length", 2); cy.contains(".cooking-recipes__item", "Soupe à l'oignon").should("be.visible"); cy.contains(".cooking-recipes__item", "Tarte à l'oignon").should("contain.text", "6 portions"); // The stepper isn't part of this tab. cy.get(".cooking-stepper").should("not.exist"); }); it("expands a recipe's detail inline on the Recettes tab instead of navigating away", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }); cy.intercept("GET", "**/recipes/1", { statusCode: 200, body: recipeDetail(1, "Soupe à l'oignon", 4, "Émincer les oignons."), }).as("getSoupe"); cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: recipeDetail(2, "Tarte à l'oignon", 6, "Étaler la pâte."), }).as("getTarte"); cy.visit("/cuisiner?date=2026-08-17"); cy.contains(".cooking-session-page__tab", "Recettes").click(); // Nothing unfolded yet, and no more link out to the catalog page — // clicking a row is what unfolds it now. cy.get(".cooking-recipes").find("a").should("not.exist"); cy.get(".cooking-recipes__detail").should("not.exist"); cy.contains(".cooking-recipes__row", "Soupe à l'oignon").click(); cy.wait("@getSoupe"); cy.location("pathname").should("eq", "/cuisiner"); // no navigation happened cy.get(".cooking-recipes__detail .recipe-detail-panel").within(() => { cy.contains("h2", "Soupe à l'oignon").should("be.visible"); cy.contains("Émincer les oignons.").should("be.visible"); }); // Only one recipe unfolds at a time — opening Tarte's closes Soupe's. cy.contains(".cooking-recipes__row", "Tarte à l'oignon").click(); cy.wait("@getTarte"); cy.get(".cooking-recipes__detail").should("have.length", 1); cy.get(".cooking-recipes__detail .recipe-detail-panel") .should("contain.text", "Étaler la pâte.") .and("not.contain.text", "Émincer"); // Clicking the open one again collapses it back. cy.contains(".cooking-recipes__row", "Tarte à l'oignon").click(); cy.get(".cooking-recipes__detail").should("not.exist"); }); it("renders a merged-setup preheat with its temperature and the oven-conflict note", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() }); cy.visit("/cuisiner?date=2026-08-17"); // The pooled preheat shows the technique label + its pooled temperature, // flagged shared like a merged-prep task. cy.get(".cooking-task--merged-setup") .should("contain.text", "180 °C") .and("contain.text", "Mutualisé"); // The phase warns the oven is needed at two more temperatures, in order. cy.get(".cooking-phase__note") .should("contain.text", "2 températures") .and("contain.text", "200 °C puis 220 °C"); // The two un-merged preheats are still listed as their own tasks. cy.contains(".cooking-task", "Préchauffer le four à 200°C").should("be.visible"); cy.contains(".cooking-task", "Préchauffer le four à 220°C").should("be.visible"); cy.contains(".cooking-plan__count", "0 / 3 tâches").should("be.visible"); }); it("splits the phase into mini sections by recipe — pooled task first, then each solo preheat under its own recipe", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() }); cy.visit("/cuisiner?date=2026-08-17"); // Three distinct recipe combinations in this phase's tasks: the pooled // preheat (Soupe + Gratin), then each un-merged preheat solo. const titles = () => cy.get(".cooking-phase__recipe-group-title"); titles().should("have.length", 3); titles().eq(0).should("have.text", "Soupe, Gratin"); titles().eq(1).should("have.text", "Gratin"); titles().eq(2).should("have.text", "Rôti"); // Each section holds only its own task. cy.get(".cooking-phase__recipe-group") .eq(0) .find(".cooking-task--merged-setup") .should("exist"); cy.get(".cooking-phase__recipe-group") .eq(1) .should("contain.text", "Préchauffer le four à 200°C") .and("not.contain.text", "220°C"); cy.get(".cooking-phase__recipe-group") .eq(2) .should("contain.text", "Préchauffer le four à 220°C") .and("not.contain.text", "200°C"); }); it("shows an error state when the request fails", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 500, body: { code: 5000, message: "boom" }, }); cy.visit("/cuisiner"); cy.contains("Impossible de charger").should("be.visible"); }); });