batchCooking/apps/api/test/recipe.test.ts
kyuno053 c34eaa89d0
chore(web): session de polish global — version, checkbox, danger zone, icônes (#20)
* chore(web): session de polish global — version, checkbox, danger zone, icônes

- Affiche le numéro de version (package.json, injecté via Vite) en bas de
  la sidebar, masqué en mode collapse et en mobile.
- Factorise les checkbox/radio dupliqués (AllergySelect, DietTagSelect,
  IngredientPicker, UserPreferencesPage) en composants partagés
  CheckboxOption/RadioOption (components/ui/), et inverse le layout pour
  que la case soit à gauche du label.
- Teinte la "zone de danger" de suppression de compte en rouge (fond +
  bordure), pas seulement le bouton.
- Migre les icônes de navigation générale vers lucide-react (nav-icons.tsx
  devient un fichier de ré-export) ; les pictogrammes d'ingrédients métier
  restent en SVG custom (pas d'équivalents fins côté lucide).

Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et
vérification visuelle manuelle (sidebar desktop/collapsed/mobile, light/dark).

* feat(web): icônes d'ingrédients depuis foodiconpack.com + page de crédits

- Remplace 19 des 22 pictogrammes génériques d'ingrédients par des icônes
  curées du pack gratuit "Common ingredient icons"/"Common Utensils" de
  foodiconpack.com (CC BY 4.0) : carotte, pomme, basilic, bœuf, poulet,
  saumon, crevette, riz, pois chiches, amandes, lait, cheddar, œufs,
  cannelle, miel, huile d'olive, bière, marmite, sucre.
- BREAD/DOUGH/SPROUT restent en SVG custom : pas d'équivalent net dans le
  pack (packs "ingrédients"/"ustensiles"/"plats"/"boissons" vérifiés).
  Architecture inchangée : `icon` reste un enum de 22 valeurs partagées en
  base (pas de migration, pas de mapping par ingrédient — cf. le
  commentaire du fichier sur l'historique emoji→enum générique).
- Nouveau wrapper FilledIcon (fill="currentColor", viewBox 2048) à côté du
  wrapper Icon existant (stroke) — les deux stylent au même endroit via
  CSS, donc le mélange des 19+3 icônes reste visuellement homogène.
- Ajoute /parametres/credits (CreditsPage) créditant foodiconpack.com et
  liant la licence CC BY 4.0, requis par la licence des icônes utilisées ;
  nouvelle entrée de nav "Crédits" (icône lucide Info).

Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et
vérification visuelle (grille des 22 icônes dans le picker, page crédits).

* feat(web,api): zone dangereuse rouge, préférences élargies, onglet favoris par défaut, e2e recettes, catalogue en uid+i18n

- Zone dangereuse (compte) : le bouton "Supprimer mon compte" est rouge.
- Pages préférences/paramétrage : contenu centré et élargi (32rem -> 56rem)
  au lieu de coller à gauche sur un écran large.
- Page recettes : l'onglet "Favoris" est sélectionné par défaut.
- Ajout de apps/web/cypress/e2e/recipes.cy.ts (onglets, recherche, sélection
  master-detail, favori, suppression, lien nouvelle recette).
- Catalogue de référence (ingrédients/régimes/allergènes) : la colonne
  `name` (le libellé français, utilisé comme clé unique) devient `key`, un
  slug stable et opaque au sens produit (ex. "vegetarien", "boeuf_hache").
  Le libellé lui-même déménage entièrement côté client, dans
  apps/web/src/locales/fr/translation.json sous le namespace `catalog.*`,
  résolu via `t(\`catalog.ingredients.${key}\`)` etc. — même schéma que
  IngredientCategory/IngredientSubcategory. Migration Prisma
  (rename + backfill des ~456 lignes déjà seedées), seed/service/tests API
  et composants web mis à jour en conséquence.
  - apps/api/src/utils/slugify.ts + scripts/generate-catalog-i18n.ts
    (regénère le fichier de traduction depuis reference-seed-data.ts).
  - 102 tests Mocha + 32 scénarios Cucumber passent contre la base migrée.

Note : cypress run plante dans cet environnement (le processus GPU
Chromium/Electron crash même headless, indépendamment des flags) — les
recipes.cy.ts n'ont pas pu être exécutés ici ; vérifiés par lecture du code
source des composants visés et par un passage manuel dans le navigateur de
prévisualisation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 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>

* fix(web): crypto.randomUUID plante hors contexte sécurisé, empêchant d'associer un ingrédient

Écran noir + "TypeError: crypto.randomUUID is not a function" au clic sur
une carte d'ingrédient dans le formulaire de recette. crypto.randomUUID()
n'est défini que dans un "contexte sécurisé" (https, ou littéralement le
host "localhost") — il est absent sur une IP locale (test sur un vrai
appareil), dans une WebView Capacitor (l'enrobage mobile prévu pour cette
app), ou en http sur un vrai domaine. RecipeFormPage/StepListEditor s'en
servaient pour générer l'identité React (`key`) de chaque ligne
d'ingrédient/étape en brouillon.

- apps/web/src/lib/client-key.ts : remplace par un générateur qui ne
  touche jamais `crypto` — un compteur + Math.random suffit, cette valeur
  n'a besoin d'être unique que le temps de la session de rendu, jamais
  envoyée au serveur.
- apps/web/cypress/e2e/recipe-form.cy.ts : couvre l'association d'un
  ingrédient (recherche, sélection, exclusion du picker une fois
  sélectionné, retrait), la création et l'édition d'une recette, et un
  test de non-régression dédié qui supprime crypto.randomUUID avant le
  chargement de la page (comme le ferait un vrai contexte non sécurisé)
  pour vérifier que l'ajout de plusieurs ingrédients/étapes ne plante
  plus.

Vérifié en direct dans le navigateur de prévisualisation en supprimant
crypto.randomUUID à la main (reproduit le crash), puis en confirmant que
l'ajout d'ingrédient fonctionne à nouveau après le correctif. cypress run
ne peut toujours pas s'exécuter dans cet environnement (voir le commit
précédent) — non exécutés avec Cypress lui-même, mais vérifiés par
lecture des sélecteurs réels et rejoués à la main dans le navigateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(ci): corrige les specs Cypress cassées par le refactor uid+i18n, applique biome

- onboarding.cy.ts / preferences.cy.ts / recipes.cy.ts mockaient encore
  GET /reference/diets|allergies avec l'ancienne forme {id, name}. Depuis
  les deux derniers commits l'API renvoie {id, key} (uid anglais) et le
  composant résout le libellé via i18n (t(`catalog.diets.${key}`)) — avec
  key manquant, ça affichait littéralement "catalog.diets.undefined" au
  lieu de "Végétarien"/"Omnivore"/etc., faisant échouer cy.select()/
  cy.contains() dans ces 3 specs. Corrigé pour mocker {key: "vegetarian"},
  {key: "peanuts"}, etc.
- recipes.cy.ts : le test "shows a not-found message" utilisait le
  mauvais code d'erreur (4041 au lieu de ErrorCode.RECIPE_NOT_FOUND =
  4045), donc RecipeDetailPanel tombait dans son état d'erreur générique
  au lieu du message "Cette recette n'existe pas." — bug dans mon propre
  test, sans rapport avec le refactor.
- pnpm lint (biome) : les fichiers touchés par le refactor précédent
  avaient quelques soucis de formatage/tri d'imports (des sed multi-
  fichiers, pas d'édition via l'outil habituel) — corrigés par
  `biome check --write`.

Vérifié : ces 3 specs + recipe-form.cy.ts passent maintenant dans le job
CI GitHub Actions (Linux, Cypress s'y exécute réellement — contrairement
à cet environnement Windows sandboxé, voir les commits précédents) ; 102
tests Mocha + 32 scénarios Cucumber toujours au vert en local.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:52:12 +02:00

535 lines
20 KiB
TypeScript

import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { getEnglishKey } from "../src/db/catalog-en-keys.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` French name (slugified to match its `key`). */
async function ingredientId(name: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey(name) },
});
return ingredient.id;
}
describe("Recipes", () => {
const app = createApp();
/** Signs up a fresh profile and returns both its session `agent` and profile id — most tests below need the id for `authorId` on directly-created fixture rows. */
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
const agent = request.agent(app);
const res = await agent.post("/auth/signup").send(buildSignupPayload());
return { agent, profileId: res.body.id };
}
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /recipes", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/recipes").query({ tab: "publique" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a missing ?tab= with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const res = await agent.get("/recipes");
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty catalog when no recipe exists yet", async () => {
const { agent } = await signup();
const res = await agent.get("/recipes").query({ tab: "publique" });
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("filters the catalog by name when ?search= is given", async () => {
const { agent, profileId } = await signup();
await prisma.recipe.create({
data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC" },
});
await prisma.recipe.create({
data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC" },
});
const res = await agent.get("/recipes").query({ tab: "publique", search: "rata" });
expect(res.status).to.equal(200);
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Ratatouille"]);
});
it("perso tab only returns the viewer's own PERSONAL recipes", async () => {
const { agent, profileId } = await signup();
const { profileId: otherId } = await signup();
await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId } });
await prisma.recipe.create({ data: { name: "Pas la mienne", authorId: otherId } });
const res = await agent.get("/recipes").query({ tab: "perso" });
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["La mienne"]);
});
it("foyer tab only returns HOUSE recipes authored within the viewer's current house", async () => {
const { agent, profileId } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const { profileId: otherId } = await signup();
const otherHouseRes = await request.agent(app).post("/house").send({ name: "Chez un autre" });
await prisma.recipe.create({
data: {
name: "Recette du foyer",
authorId: profileId,
visibility: "HOUSE",
authorHouseId: houseRes.body.id,
},
});
await prisma.recipe.create({
data: {
name: "Recette d'un autre foyer",
authorId: otherId,
visibility: "HOUSE",
authorHouseId: otherHouseRes.body.id,
},
});
const res = await agent.get("/recipes").query({ tab: "foyer" });
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Recette du foyer"]);
});
it("foyer tab is empty when the viewer has no household", async () => {
const { agent } = await signup();
const res = await agent.get("/recipes").query({ tab: "foyer" });
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("favoris tab only returns recipes the viewer has favorited", async () => {
const { agent, profileId } = await signup();
const favorited = await prisma.recipe.create({
data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC" },
});
await prisma.recipe.create({
data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC" },
});
await agent.post(`/recipes/${favorited.id}/favorite`);
const res = await agent.get("/recipes").query({ tab: "favoris" });
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Favorite"]);
});
it("a PERSONAL recipe from another author is invisible in the publique tab", async () => {
const { agent } = await signup();
const { profileId: otherId } = await signup();
await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
const res = await agent.get("/recipes").query({ tab: "publique" });
expect(res.body).to.deep.equal([]);
});
});
describe("POST /recipes", () => {
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const oeuf = await ingredientId("Œuf");
const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: getEnglishKey("Végétarien") },
});
const res = await agent.post("/recipes").send({
name: "Omelette provençale",
description: "Rapide et savoureuse",
dietIds: [vegetarien.id],
ingredients: [
{ ingredientId: tomate, quantity: 2, unit: "unité" },
{ ingredientId: oeuf, quantity: 3, unit: "unité" },
],
steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }],
});
expect(res.status).to.equal(201);
expect(res.body.name).to.equal("Omelette provençale");
expect(res.body.ingredients).to.have.length(2);
expect(
res.body.steps.map((s: { description: string; order: number }) => s.order),
).to.deep.equal([0, 1]);
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include(
getEnglishKey("Œufs"),
);
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
getEnglishKey("Végétarien"),
]);
});
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
const { agent } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const tomate = await ingredientId("Tomate");
const res = await agent.post("/recipes").send({
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
});
expect(res.body.visibility).to.equal("PERSONAL");
// authorHouseId isn't in the API response, but the "foyer" tab
// proves it was stamped — a HOUSE recipe created next should show up.
const houseRecipe = await agent.post("/recipes").send({
name: "Foyer",
visibility: "HOUSE",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
});
const foyerRes = await agent.get("/recipes").query({ tab: "foyer" });
expect(foyerRes.body.map((r: { id: number }) => r.id)).to.include(houseRecipe.body.id);
expect(houseRes.body.id).to.be.a("number"); // house exists, sanity check
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
const { agent } = await signup();
const res = await agent.post("/recipes").send({
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const res = await agent.post("/recipes").send({
name: "Test",
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const res = await agent
.post("/recipes")
.send({ name: "Test", dietIds: [], ingredients: [], steps: [{ description: "Étape" }] });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
describe("GET /recipes/:id", () => {
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup();
const res = await agent.get("/recipes/999999");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("returns the full recipe detail", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const created = await agent.post("/recipes").send({
name: "Salade",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }],
});
const res = await agent.get(`/recipes/${created.body.id}`);
expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade");
expect(res.body.ingredients[0].ingredient.key).to.equal(getEnglishKey("Tomate"));
expect(res.body.isFavorite).to.equal(false);
});
it("returns 404 for a PERSONAL recipe belonging to someone else", async () => {
const { agent } = await signup();
const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
const res = await agent.get(`/recipes/${recipe.id}`);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("returns 200 for a PUBLIC recipe belonging to someone else", async () => {
const { agent } = await signup();
const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC" },
});
const res = await agent.get(`/recipes/${recipe.id}`);
expect(res.status).to.equal(200);
});
it("returns 200 for a HOUSE recipe shared with the viewer's household, 404 otherwise", async () => {
const { agent } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const { profileId: otherId } = await signup();
const inHouse = await prisma.recipe.create({
data: {
name: "Du foyer",
authorId: otherId,
visibility: "HOUSE",
authorHouseId: houseRes.body.id,
},
});
const otherHouseId = (
await prisma.house.create({
data: { name: "Autre", adminId: otherId, inviteCode: "TESTHOUS" },
})
).id;
const outsideHouse = await prisma.recipe.create({
data: {
name: "D'un autre foyer",
authorId: otherId,
visibility: "HOUSE",
authorHouseId: otherHouseId,
},
});
const inHouseRes = await agent.get(`/recipes/${inHouse.id}`);
const outsideHouseRes = await agent.get(`/recipes/${outsideHouse.id}`);
expect(inHouseRes.status).to.equal(200);
expect(outsideHouseRes.status).to.equal(404);
});
});
describe("PATCH /recipes/:id", () => {
it("replaces the recipe's whole content", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const oignon = await ingredientId("Oignon");
const created = await agent.post("/recipes").send({
name: "Salade",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade composée",
visibility: "PUBLIC",
dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }],
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
});
expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade composée");
expect(res.body.visibility).to.equal("PUBLIC");
expect(res.body.ingredients).to.have.length(1);
expect(res.body.ingredients[0].ingredient.key).to.equal(getEnglishKey("Oignon"));
expect(res.body.steps).to.have.length(2);
});
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const res = await agent.patch("/recipes/999999").send({
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const created = await agent.post("/recipes").send({
name: "Salade",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade",
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
const { agent, profileId } = await signup();
const { agent: otherAgent } = await signup();
const tomate = await ingredientId("Tomate");
const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" },
});
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
name: "Hack",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(403);
expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
});
});
describe("DELETE /recipes/:id", () => {
it("deletes a recipe not referenced by any planning item", async () => {
const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "À supprimer", authorId: profileId },
});
const res = await agent.delete(`/recipes/${recipe.id}`);
expect(res.status).to.equal(204);
const getRes = await agent.get(`/recipes/${recipe.id}`);
expect(getRes.status).to.equal(404);
});
it("rejects deleting a recipe still used by a planning item with 409 RECIPE_IN_USE", async () => {
const { agent, profileId } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const recipe = await prisma.recipe.create({
data: { name: "Ratatouille", authorId: profileId },
});
const planning = await prisma.planning.create({
data: {
houseId: houseRes.body.id,
startDate: new Date(Date.UTC(2026, 0, 1)),
finishDate: new Date(Date.UTC(2026, 0, 7)),
},
});
await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
});
const res = await agent.delete(`/recipes/${recipe.id}`);
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.RECIPE_IN_USE);
});
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup();
const res = await agent.delete("/recipes/999999");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("rejects deleting someone else's recipe with 403 NOT_RECIPE_AUTHOR", async () => {
const { profileId } = await signup();
const { agent: otherAgent } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" },
});
const res = await otherAgent.delete(`/recipes/${recipe.id}`);
expect(res.status).to.equal(403);
expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
});
});
describe("POST/DELETE /recipes/:id/favorite", () => {
it("adds and removes a recipe from the viewer's favorites", async () => {
const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" },
});
const addRes = await agent.post(`/recipes/${recipe.id}/favorite`);
expect(addRes.status).to.equal(204);
expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(true);
const removeRes = await agent.delete(`/recipes/${recipe.id}/favorite`);
expect(removeRes.status).to.equal(204);
expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(false);
});
it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => {
const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" },
});
await agent.post(`/recipes/${recipe.id}/favorite`);
const res = await agent.post(`/recipes/${recipe.id}/favorite`);
expect(res.status).to.equal(204);
});
it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => {
const { agent } = await signup();
const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
const res = await agent.post(`/recipes/${recipe.id}/favorite`);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
});
});