// 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 API // behavior against a real database. // // The favorite-toggle and delete-recipe journeys moved to recipes.feature — // this file now only covers catalog browsing/display. 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, portions: 4, authorId: 1, visibility: "PERSONAL", allergens: [], diets: [vegetarien], isFavorite: true, }; const omelette = { id: 2, name: "Omelette", description: null, picture: null, portions: 2, 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: "dairyAndCheese", subcategory: "eggs", allergens: [oeufs], diets: [], }, quantity: 3, unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }, }, ], steps: [ { id: 1, description: "Battre les œufs.", picture: null, order: 1, techSteps: [] }, { id: 2, description: "Cuire à la poêle.", picture: null, order: 2, techSteps: [] }, ], }; /** * A 3-step recipe whose middle step already has an existing technique match * carrying attached ingredient/utensil metadata — for the step-numbering * regression test below, which needs a correction popover with several * `
  • ` rows of its own open at once (see that test's own comment). */ const tarteDetail = { id: 5, name: "Tarte", description: null, picture: null, portions: 4, authorId: 1, visibility: "PERSONAL", allergens: [], diets: [], isFavorite: false, ingredients: [], steps: [ { id: 1, description: "Préchauffer le four.", picture: null, order: 1, techSteps: [] }, { id: 2, description: "Faire revenir les oignons avec une poêle.", picture: null, order: 2, techSteps: [ { techStep: { id: 2, key: "panFry" }, start: 0, end: 13, source: "auto", ingredients: [ { ingredient: { id: 10, key: "onion", icon: "VEGETABLE", category: "freshProduce", subcategory: "vegetables", allergens: [], diets: [], }, quantity: 2, unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 }, start: 18, end: 25, source: "auto", }, ], utensils: [{ utensil: { id: 20, key: "pan" }, start: 31, end: 40, source: "auto" }], temperatures: [], }, ], }, { id: 3, description: "Servir chaud.", picture: null, order: 3, techSteps: [] }, ], }; /** * A one-step recipe whose single technique match carries no attached * metadata yet — for the issue #36 regression test below, which needs a * clean correction popover and a description word ("oignons") that appears * *only* in the step text, nowhere in the popover itself. */ const brownStepRecipe = { id: 6, name: "Soupe", description: null, picture: null, portions: 4, authorId: 1, visibility: "PERSONAL", allergens: [], diets: [], isFavorite: false, ingredients: [], steps: [ { id: 1, description: "Faire revenir les oignons.", picture: null, order: 1, techSteps: [ { techStep: { id: 3, key: "brown" }, start: 0, end: 13, source: "auto", ingredients: [], utensils: [], temperatures: [], }, ], }, ], }; /** * A recipe whose step object is missing `techSteps` entirely (not `[]`) — * a partial/older payload shape. `StepDescription` must render its * description as plain text rather than crashing on `liveTechSteps.flatMap`. * Regression: an intermittent `recipe-form.feature` failure where saving an * edited recipe navigates to `/recettes/:id`, whose detail loaded before * `techSteps` was populated. */ const techStepsMissingDetail = { id: 7, name: "Omelette", description: null, picture: null, portions: 2, authorId: 1, visibility: "PERSONAL", allergens: [], diets: [], isFavorite: false, ingredients: [], steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 1 }], }; 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"); }); 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"); }); }); // Regression: a step payload missing `techSteps` entirely (see // `techStepsMissingDetail`) made `StepDescription` throw // `Cannot read properties of undefined (reading 'flatMap')`, surfacing as // an uncaught application error. An uncaught exception fails the spec on // its own, so simply asserting the description renders is enough — no // extra `cy.on("uncaught:exception")` needed. it("renders a step whose techSteps field is absent from the payload, as plain text", () => { cy.viewport(1600, 900); cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: techStepsMissingDetail }).as( "getRecipe", ); cy.visit("/recettes/7"); cy.wait("@getRecipe"); cy.get(".recipe-detail-panel__steps").within(() => { cy.contains("Battre les œufs.").should("be.visible"); }); }); // Regression: `.recipe-detail-panel__steps`'s CSS step counter // (recipes.scss) used a bare descendant `li` selector, matching *any* // `
  • ` nested inside a step — including ones several levels down // inside `TechStepCorrectionPopover` (each attached ingredient/utensil/ // temperature row, and the technique picker's own `
  • ` list). Opening // a correction with attached metadata inflated the counter, throwing off // every *later* step's own displayed number — exactly the "les étapes // affichées sont numérotées n'importe comment" bug report this covers. it("keeps every step's own number correct while a correction popover with attached metadata is open", () => { cy.viewport(1600, 900); cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", "**/recipes/5", { statusCode: 200, body: tarteDetail }); cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [{ id: 2, key: "panFry" }], }); cy.visit("/recettes/5"); cy.get(".recipe-detail-panel__steps > li").should("have.length", 3); // Open the middle step's correction — pre-seeded with one ingredient // and one utensil, so its popover renders two `.tech-step-correction-popover__row` // `
  • `s of its own (plus the technique picker's own `
  • ` list once // "Changer" is clicked) sitting *inside* the second step's own `
  • `. cy.contains(".step-tech-step", "Faire revenir").click(); cy.get(".tech-step-correction-popover__row").should("have.length", 2); cy.contains(".tech-step-correction-popover__technique-change", "Changer").click(); cy.get(".catalog-search-picker__list li").should("have.length.greaterThan", 0); // None of the popover's own nested `
  • `s join the step counter chain // — `getComputedStyle(...).content` isn't reliable for reading a // *resolved* counter() value across browsers (it can return the raw // `counter(step) ". "` declaration instead), so this checks the actual // `counter-increment` computed property instead: a real, directly // testable signal for exactly what `> li` (recipes.scss) does and // doesn't scope to. cy.get(".tech-step-correction-popover__row").each(($row) => { expect(window.getComputedStyle($row[0]).counterIncrement).to.equal("none"); }); cy.get(".catalog-search-picker__list li").each(($item) => { expect(window.getComputedStyle($item[0]).counterIncrement).to.equal("none"); }); // The step `
  • `s themselves are still correctly in the chain. cy.get(".recipe-detail-panel__steps > li").each(($step) => { expect(window.getComputedStyle($step[0]).counterIncrement).to.equal("step 1"); }); }); // Regression (issue #36): while the correction popover's "add an // ingredient/utensil/temperature" flow waits for a text selection, the // mousedown that *starts* that drag lands on the step description — a DOM // sibling of the popover — and used to count as an outside-click, // closing the popover before mouseup; the selection then opened a fresh // blank popover for the selected word as a brand-new technique. The // description container is now exempt from the popover's outside-click. it("keeps the same correction popover open while selecting a span for an ingredient", () => { cy.viewport(1600, 900); cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", "**/recipes/6", { statusCode: 200, body: brownStepRecipe }); cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [{ id: 3, key: "brown" }], }); cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] }); cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [] }); cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [] }); cy.visit("/recettes/6"); // Open the technique correction, then ask to attach an ingredient — the // popover now shows the "select the span in the description" hint. cy.contains(".step-tech-step", "Faire revenir").click(); cy.get(".tech-step-correction-popover").should("have.length", 1); cy.get(".tech-step-correction-popover__selection").should("contain.text", "Faire revenir"); cy.contains(".tech-step-correction-popover__add-row", "Ajouter un ingrédient").click(); cy.get(".tech-step-correction-popover__hint").should("be.visible"); // Reproduce the real mousedown → drag → mouseup over the description. // The mousedown is the event that used to close the popover. cy.contains(".recipe-detail-panel__steps p", "Faire revenir").as("stepParagraph"); cy.get("@stepParagraph").trigger("mousedown"); cy.window().then((win) => { const paragraph = win.document.querySelector(".recipe-detail-panel__steps p") as HTMLElement; const span = [...paragraph.querySelectorAll("[data-offset]")].find((el) => el.textContent?.includes("oignons"), ); const textNode = span?.firstChild as Text; const local = textNode.textContent?.indexOf("oignons") ?? 0; const range = win.document.createRange(); range.setStart(textNode, local); range.setEnd(textNode, local + "oignons".length); const selection = win.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); }); cy.get("@stepParagraph").trigger("mouseup"); // Still the *same* popover — its heading still quotes the technique span // "Faire revenir", not "oignons" — now showing the ingredient sub-form // for the freshly selected span, not a blank new-technique popover. cy.get(".tech-step-correction-popover").should("have.length", 1); cy.get(".tech-step-correction-popover__selection").should("contain.text", "Faire revenir"); cy.get(".tech-step-correction-popover__add-form").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("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", ); }); });