batchCooking/apps/web/cypress/e2e/recipes.cy.ts
Nicolas 77b55d7aeb refactor(catalog): authoring 100% anglais — uid camelCase, plus de français dans le code
Le catalogue de référence (diets/allergènes/ingrédients) était écrit en
français dans reference-seed-data.ts, avec une table de correspondance
séparée (catalog-en-keys.ts, 666 lignes, ~563 entrées) traduisant chaque
libellé français vers une clé anglaise snake_case, elle-même utilisée pour
peupler la colonne `key` en base et régénérer translation.json. Décision :
remplacer par un authoring 100% anglais camelCase directement dans le seed
— plus de détour, plus de table de correspondance.

- `Ingredient.name`/`allergenNames`/`dietNames` → `uid`/`allergenUids`/
  `dietUids`, valeurs en camelCase directement (ex: "Tomate" → "tomato",
  "Fruits à coque" → "treeNuts").
- `IngredientCategory`/`IngredientSubcategory` (enums Prisma) renommés du
  français SCREAMING_SNAKE_CASE (`PRODUITS_FRAIS`, `LEGUMES`...) vers
  l'anglais camelCase (`freshProduce`, `vegetables`...) — même mécanique
  de migration que le renommage d'enum précédent
  (20260818113250_ingredient_taxonomy_rework) : nouvelle colonne avec
  valeur par défaut sûre, jamais de cast direct (aucune valeur commune
  entre ancien et nouvel enum), seedReferenceData() corrige chaque ligne
  au démarrage suivant.
- Migration `20260819180000_catalog_camel_case_uids` : renomme les clés
  existantes (diet/category/ingredients, même mécanique que
  20260818193000_catalog_keys_to_english) + swap des deux enums. Un cas
  particulier corrigé à la main : "sesame_seeds" était à la fois la clé
  d'un allergène (Category) et d'un ingrédient qui se référence lui-même
  ("Graines de sésame") — les deux tables ont besoin de leur propre
  UPDATE.
- `catalog-en-keys.ts`, `slugify.ts`, `generate-catalog-i18n.ts`,
  `validate-catalog-en-keys.ts` — supprimés (plus de raison d'être).
  Conséquence assumée : `translation.json` n'est plus régénéré
  automatiquement, c'est désormais la seule source du texte FR, tenue à
  jour à la main en parallèle du uid (même clé qui les relie).
- `packages/shared/src/types/reference.ts`, `apps/web`'s
  `ingredient-icons.tsx` (CATEGORY_ICON/SUBCATEGORY_ICON), fixtures
  Cypress codées en dur — mis à jour avec les nouveaux noms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:40:55 +02:00

217 lines
7.7 KiB
TypeScript
Raw Permalink 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 — 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",
);
});
});