batchCooking/apps/api/features/step-definitions/recipe.steps.ts
Nicolas 4b85531601 fix(api): les uids du catalogue sont en anglais, pas des slugs français
reference-seed-data.ts reste rédigé en français (c'est juste le libellé
d'autoring, jamais stocké/exposé), mais la clé stable (`Diet.key`/
`Category.key`/`Ingredient.key`) qu'on en dérive doit elle-même être un
identifiant anglais, indépendant de la langue d'autoring — pas juste le
même texte français passé à slugify().

- apps/api/src/db/catalog-en-keys.ts : dictionnaire écrit à la main
  (label français -> clé anglaise) pour les 5 régimes, 14 allergènes et
  437 ingrédients ; getEnglishKey() lève une erreur explicite si un
  nouvel élément n'a pas encore d'entrée plutôt que de retomber sur un
  slug français silencieux.
- scripts/validate-catalog-en-keys.ts : vérifie que chaque diet/allergène/
  ingrédient de reference-seed-data.ts a une entrée, et que les clés
  anglaises résultantes sont uniques (437/437, 14/14, 5/5 — zéro manquant,
  zéro collision).
- reference-seed-data.ts et scripts/generate-catalog-i18n.ts utilisent
  désormais getEnglishKey() au lieu de slugify(nom français).
- Nouvelle migration (20260818193000_catalog_keys_to_english) qui
  remappe les lignes déjà seedées avec un slug français (par la migration
  précédente) vers leur clé anglaise définitive.
- apps/web/src/locales/fr/translation.json régénéré : catalog.* est
  maintenant indexé par clé anglaise ("vegetarian", "eggs",
  "ground_beef"...), toujours avec le libellé français en valeur.
- Tests/step-definitions mis à jour (getEnglishKey() au lieu de
  slugify()) ; 102 tests Mocha + 32 scénarios Cucumber passent contre la
  base migrée. Vérifié aussi en direct via GET /reference/diets et
  /reference/allergies.

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

166 lines
6.7 KiB
TypeScript

import assert from "node:assert/strict";
import { Given, Then, When } from "@cucumber/cucumber";
import { prisma } from "../../src/db/prisma.js";
import { getEnglishKey } from "../../src/db/catalog-en-keys.js";
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
import type { CustomWorld } from "../support/world.js";
/**
* Resolves a reference ingredient by its seeded French name — every
* scenario below names an ingredient by its `reference-seed-data.ts` name,
* never a raw id or its slug `key` directly, so this slugifies before
* matching.
*/
async function findIngredientId(name: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key: getEnglishKey(name) } });
return ingredient.id;
}
When("I request the recipe catalog", async function (this: CustomWorld) {
this.response = await this.agent.get("/recipes");
});
When("I request the recipe catalog tab {string}", async function (this: CustomWorld, tab: string) {
this.response = await this.agent.get("/recipes").query({ tab });
});
Then(
"the recipe catalog response should include {string}",
function (this: CustomWorld, name: string) {
const names = (this.response.body as Array<{ name: string }>).map((recipe) => recipe.name);
assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`);
},
);
When(
"I create a recipe named {string} with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
this.response = await this.agent.post("/recipes").send({
name,
dietIds: [],
ingredients: [{ ingredientId, quantity: 1, unit: "unité" }],
steps: [{ description: step }],
});
},
);
When(
"I create a recipe named {string} with unknown ingredient id {int} and step {string}",
async function (this: CustomWorld, name: string, unknownIngredientId: number, step: string) {
this.response = await this.agent.post("/recipes").send({
name,
dietIds: [],
ingredients: [{ ingredientId: unknownIngredientId, quantity: 1, unit: "unité" }],
steps: [{ description: step }],
});
},
);
Then(
"the created recipe should have ingredient {string} and step {string}",
function (this: CustomWorld, ingredientName: string, step: string) {
const body = this.response.body as {
ingredients: Array<{ ingredient: { key: string } }>;
steps: Array<{ description: string }>;
};
const expectedKey = getEnglishKey(ingredientName);
assert.ok(body.ingredients.some((line) => line.ingredient.key === expectedKey));
assert.ok(body.steps.some((s) => s.description === step));
},
);
// Created directly via Prisma (with a nested ingredient + step), not through
// the API — same rationale as `planning.steps.ts`'s equivalent "already
// exists" step: this is background state the scenario needs in place before
// its actual `When`, not the behavior under test. `authorId` is the
// currently-logged-in agent's own profile — `visibility` defaults to
// `PERSONAL` (schema.prisma), matching a recipe this agent just created for
// themselves.
Given(
"a recipe named {string} already exists with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
const me = await this.agent.get("/auth/me");
await prisma.recipe.create({
data: {
name,
authorId: me.body.id,
ingredients: { create: [{ ingredientId, quantity: 1, unit: "unité" }] },
steps: { create: [{ description: step, order: 0 }] },
},
});
},
);
// Same as above but `visibility: PUBLIC` — needed for scenarios where a
// *second* user must be able to see (though not necessarily edit) the
// recipe, e.g. the "only the author can edit" scenario: a `PERSONAL`
// recipe would 404 for anyone else before the authorship check even runs
// (see `recipe.service.ts`'s `canView`).
Given(
"a public recipe named {string} already exists with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
const me = await this.agent.get("/auth/me");
await prisma.recipe.create({
data: {
name,
authorId: me.body.id,
visibility: "PUBLIC",
ingredients: { create: [{ ingredientId, quantity: 1, unit: "unité" }] },
steps: { create: [{ description: step, order: 0 }] },
},
});
},
);
// Distinct from `planning.steps.ts`'s "my household has a planning covering
// today with recipe {string}..." — that step always creates a *new* recipe
// row with the given name, which wouldn't exercise the actual `RECIPE_IN_USE`
// check against a recipe this feature already created. This step instead
// looks up the already-existing recipe by name and points the planning item
// at its real id.
Given(
"my household has a planning that uses the recipe named {string}",
async function (this: CustomWorld, recipeName: string) {
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name: recipeName } });
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
});
},
);
When("I delete the recipe named {string}", async function (this: CustomWorld, name: string) {
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.response = await this.agent.delete(`/recipes/${recipe.id}`);
});
When("I favorite the recipe named {string}", async function (this: CustomWorld, name: string) {
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.response = await this.agent.post(`/recipes/${recipe.id}/favorite`);
});
When(
"the second user tries to modify the recipe named {string}",
async function (this: CustomWorld, name: string) {
const ingredientId = await findIngredientId("Tomate");
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.secondResponse = await this.secondAgent.patch(`/recipes/${recipe.id}`).send({
name,
dietIds: [],
ingredients: [{ ingredientId, quantity: 1, unit: "unité" }],
steps: [{ description: "Hack" }],
});
},
);