// 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.
import { PHONE_VIEWPORT } from "../support/viewports";
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",
);
});
});
// Ticket #90 — `.recipe-table-wrap` had no horizontal-scroll guard at all
// (unlike the planning grid's own wrapper), so a narrow viewport just let
// the browser compress every column instead of scrolling. Below the
// tablet breakpoint (1024px) the master-detail split already collapses to
// one column (recipes.scss's own `bp.tablet-down` rule) and gives the
// table the full page width — still comfortably under `.recipe-table`'s
// own 36rem floor on a phone, which is what this covers.
describe("Recipe catalog (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
interceptAuth();
});
it("keeps the table on its own horizontal scroll, at full width, instead of squeezing its columns", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
cy.visit("/recettes");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.get(".recipe-table-wrap").should(($el) => {
const s = getComputedStyle($el[0]);
expect(s.overflowX).to.be.oneOf(["auto", "scroll"]);
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
});
// ...and that overflow stays *inside* the wrapper — same global rule
// every other page is held to.
cy.document()
.its("documentElement.scrollWidth")
.should("be.lte", PHONE_VIEWPORT.width + 1);
});
});