// 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, 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: "dairyAndCheese", subcategory: "eggs", 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("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", ); }); });