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