batchCooking/apps/api/test/recipe.test.ts
Nicolas abc5b0a0e9 fix(recipes): corrige plusieurs bugs d'import TheMealDB
- Les instructions TheMealDB numérotées sur leur propre ligne ("1\n\ntexte...\n\n2\n\ntexte...") créaient des étapes parasites ne contenant qu'un chiffre — filtrées désormais (#52).
- Un ingrédient compté sans mot d'unité dans le texte source (ex. "4 Egg Yolks") laissait l'import bloqué sur "Importer" indéfiniment, sans indication visuelle de la ligne en cause — matchUnit retombe maintenant sur l'unité générique "piece" quand une quantité a été extraite, et RecipeImportForm/RecipeFormPage surlignent désormais toute ligne dont l'unité manque, avec un message explicite (#53).
- Ajout de INGREDIENT_LABEL_SYNONYMS_EN pour reconnaître des formulations alternatives fréquentes chez les sources anglophones ("vanilla pod" en plus de "vanilla bean") sans élargir INGREDIENT_LABELS_EN à un tableau pour ses ~550 entrées (#54).
- Effet de bord découvert en vérifiant #53 de bout en bout : deux lignes source résolues vers le même ingrédient catalogue (ex. "Egg Yolks"/"Eggs" -> "Œuf") faisaient planter la création en 500 (contrainte unique recipe_id+ingredient_id) au lieu d'un 400 propre. createRecipeSchema rejette maintenant les ingredientId en double, et le formulaire d'import surligne les doublons avant même de soumettre.

Vérifié de bout en bout dans le navigateur (import réel de la recette "Flan" depuis TheMealDB, jusqu'au planning) en plus des tests ajoutés.

Closes #52, #53, #54

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

1017 lines
39 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 { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return {
key,
name,
official: false,
iconUrl: null,
locale: "fr",
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail() {
throw new Error("not implemented");
},
parse() {
throw new Error("not implemented");
},
};
}
/** 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` uid (also its DB `key`). */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
async function techStepId(key: string): Promise<number> {
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
return techStep.id;
}
/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/tech-step-matcher.js`) so tests can assert on it directly. */
async function stepTechStepIds(stepId: number): Promise<number[]> {
const links = await prisma.stepTechStep.findMany({
where: { stepId },
orderBy: { order: "asc" },
select: { techStepId: true },
});
return links.map((link) => link.techStepId);
}
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", portions: 4 },
});
await prisma.recipe.create({
data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC", portions: 6 },
});
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, portions: 4 } });
await prisma.recipe.create({
data: { name: "Pas la mienne", authorId: otherId, portions: 4 },
});
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,
portions: 4,
},
});
await prisma.recipe.create({
data: {
name: "Recette d'un autre foyer",
authorId: otherId,
visibility: "HOUSE",
authorHouseId: otherHouseRes.body.id,
portions: 4,
},
});
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", portions: 4 },
});
await prisma.recipe.create({
data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 },
});
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, portions: 4 } });
const res = await agent.get("/recipes").query({ tab: "publique" });
expect(res.body).to.deep.equal([]);
});
describe("source visibility", () => {
afterEach(() => {
clearRecipeSources();
});
/** Registers+syncs a throwaway adapter and returns the `Source` row `syncRecipeSources` created for it. */
async function registerAndSyncSource(key: string) {
registerRecipeSource(buildFakeAdapter(key, key));
await syncRecipeSources(prisma);
return prisma.source.findUniqueOrThrow({ where: { key } });
}
it("always shows a manually-authored recipe, even for a household with nothing enabled", async () => {
const { agent, profileId } = await signup();
await agent.post("/house").send({ name: "Chez moi" });
await prisma.recipe.create({
data: { name: "Maison", authorId: profileId, visibility: "PUBLIC", portions: 4 },
});
const res = await agent.get("/recipes").query({ tab: "publique" });
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Maison"]);
});
it("hides a sourced recipe until the viewer's household enables that source, then shows it", async () => {
const { agent, profileId } = await signup();
await agent.post("/house").send({ name: "Chez moi" });
const source = await registerAndSyncSource("fakeSource");
await prisma.recipe.create({
data: {
name: "Importée",
authorId: profileId,
visibility: "PUBLIC",
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
const hidden = await agent.get("/recipes").query({ tab: "publique" });
expect(hidden.body).to.deep.equal([]);
const patchRes = await agent
.patch("/house/current/sources")
.send({ sourceIds: [source.id] });
expect(patchRes.status).to.equal(200);
const visible = await agent.get("/recipes").query({ tab: "publique" });
expect(visible.body.map((r: { name: string }) => r.name)).to.deep.equal(["Importée"]);
});
it("hides a disabled-source recipe in every tab, including the viewer's own perso and favoris", async () => {
const { agent, profileId } = await signup();
await agent.post("/house").send({ name: "Chez moi" });
const source = await registerAndSyncSource("fakeSource");
const recipe = await prisma.recipe.create({
data: {
name: "Ma recette importée",
authorId: profileId,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
await prisma.recipeFavorite.create({
data: { userProfileId: profileId, recipeId: recipe.id },
});
expect((await agent.get("/recipes").query({ tab: "perso" })).body).to.deep.equal([]);
expect((await agent.get("/recipes").query({ tab: "favoris" })).body).to.deep.equal([]);
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const persoAfter = await agent.get("/recipes").query({ tab: "perso" });
expect(persoAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
"Ma recette importée",
]);
const favorisAfter = await agent.get("/recipes").query({ tab: "favoris" });
expect(favorisAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
"Ma recette importée",
]);
});
it("hides every sourced recipe for a viewer with no household at all", async () => {
const { agent, profileId } = await signup();
const source = await registerAndSyncSource("fakeSource");
await prisma.recipe.create({
data: {
name: "Importée",
authorId: profileId,
visibility: "PUBLIC",
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
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("tomato");
const oeuf = await ingredientId("egg");
const piece = await unitId("piece");
const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: "vegetarian" },
});
const res = await agent.post("/recipes").send({
name: "Omelette provençale",
description: "Rapide et savoureuse",
portions: 2,
dietIds: [vegetarien.id],
ingredients: [
{ ingredientId: tomate, quantity: 2, unitId: piece },
{ ingredientId: oeuf, quantity: 3, unitId: piece },
],
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.portions).to.equal(2);
expect(res.body.ingredients).to.have.length(2);
expect(res.body.ingredients[0].unit.key).to.equal("piece");
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("eggs");
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]);
});
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("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
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",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
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("auto-detects a step's technique from its description and persists it", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const simmer = await techStepId("simmer");
const description = "Faire mijoter à feu doux pendant 30 minutes";
const res = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }],
});
expect(res.status).to.equal(201);
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
expect(await stepTechStepIds(step.id)).to.deep.equal([simmer]);
// Exposed via StepView too — the whole point of persisting start/end
// (see tech-step-matcher.ts's matchTechStepSpans) is that the API
// response itself carries exactly what to highlight, not just the id.
const resStep = res.body.steps[0];
expect(resStep.techSteps).to.have.length(1);
expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
const { start, end } = resStep.techSteps[0];
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
});
it("leaves a step's technique sequence empty when its description matches no known technique", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
expect(await stepTechStepIds(step.id)).to.deep.equal([]);
});
it("detects each step's technique(s) independently, preserving order", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const simmer = await techStepId("simmer");
const chop = await techStepId("chop");
const res = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [
{ description: "Hacher les oignons" },
{ description: "Servir immédiatement" },
{ description: "Faire mijoter à feu doux" },
],
});
expect(res.status).to.equal(201);
const steps = await prisma.step.findMany({
where: { recipeId: res.body.id },
orderBy: { order: "asc" },
});
expect(await Promise.all(steps.map((s) => stepTechStepIds(s.id)))).to.deep.equal([
[chop],
[],
[simmer],
]);
});
it("picks the more specific technique end-to-end when a description matches more than one", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const bake = await techStepId("bake");
const res = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
steps: [{ description: "Cuire au four pendant 30 minutes" }],
});
expect(res.status).to.equal(201);
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
expect(await stepTechStepIds(step.id)).to.deep.equal([bake]);
});
it("detects a sequence of several distinct techniques within a single step, in reading order", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const preheat = await techStepId("preheat");
const melt = await techStepId("melt");
const description = "Préchauffer la poêle, puis faire fondre le beurre";
const res = await agent.post("/recipes").send({
name: "Poêlée",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// The case that motivated the sequence model: one instruction, two techniques.
steps: [{ description }],
});
expect(res.status).to.equal(201);
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
expect(await stepTechStepIds(step.id)).to.deep.equal([preheat, melt]);
// Each entry's span, sliced back out of the description, is exactly
// the word(s) that triggered that particular match.
const resTechSteps = res.body.steps[0].techSteps;
expect(resTechSteps.map((t: { techStep: { key: string } }) => t.techStep.key)).to.deep.equal([
"preheat",
"melt",
]);
expect(description.slice(resTechSteps[0].start, resTechSteps[0].end).toLowerCase()).to.equal(
"préchauffer",
);
expect(description.slice(resTechSteps[1].start, resTechSteps[1].end).toLowerCase()).to.equal(
"faire fondre",
);
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
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",
portions: 4,
dietIds: [],
ingredients: [],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — a
// manual creation can't reach this via the web UI (`IngredientPicker`
// hides an already-picked ingredient), but nothing stops a raw
// request (or a source import, whose lines aren't deduplicated) from
// sending it — must fail cleanly instead of crashing on the DB's
// unique-constraint violation.
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: tomate, quantity: 1, unitId: piece },
{ ingredientId: tomate, quantity: 2, unitId: piece },
],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const basePayload = {
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }],
};
const missing = await agent.post("/recipes").send(basePayload);
const zero = await agent.post("/recipes").send({ ...basePayload, portions: 0 });
expect(missing.status).to.equal(400);
expect(missing.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(zero.status).to.equal(400);
expect(zero.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("tomato");
const piece = await unitId("piece");
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
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.portions).to.equal(4);
expect(res.body.ingredients[0].ingredient.key).to.equal("tomato");
expect(res.body.ingredients[0].unit.key).to.equal("piece");
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, portions: 4 },
});
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", portions: 4 },
});
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,
portions: 4,
},
});
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,
portions: 4,
},
});
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("tomato");
const oignon = await ingredientId("onion");
const piece = await unitId("piece");
const gram = await unitId("gram");
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade composée",
portions: 6,
visibility: "PUBLIC",
dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
});
expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade composée");
expect(res.body.portions).to.equal(6);
expect(res.body.visibility).to.equal("PUBLIC");
expect(res.body.ingredients).to.have.length(1);
expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
expect(res.body.ingredients[0].unit.key).to.equal("gram");
expect(res.body.steps).to.have.length(2);
});
it("recomputes each replaced step's technique sequence", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const mince = await techStepId("mince");
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Émincer les tomates" }],
});
expect(res.status).to.equal(200);
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.body.id } });
expect(await stepTechStepIds(step.id)).to.deep.equal([mince]);
});
it("recomputes techniques from scratch on every edit — modifying, adding, and removing a step all take effect, nothing stale survives", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const chop = await techStepId("chop");
const mince = await techStepId("mince");
const melt = await techStepId("melt");
const simmer = await techStepId("simmer");
const bake = await techStepId("bake");
const created = await agent.post("/recipes").send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [
{ description: "Hacher les oignons" }, // chop
{ description: "Faire mijoter à feu doux" }, // simmer
{ description: "Cuire au four" }, // bake
],
});
expect(created.status).to.equal(201);
const originalStepIds = (
await prisma.step.findMany({ where: { recipeId: created.body.id } })
).map((s) => s.id);
expect(originalStepIds).to.have.length(3);
// Sanity check before the edit — each original step really did get a
// techStepId chop/simmer/bake (proves the later assertions are
// actually about recomputation, not about it never having matched).
expect((await Promise.all(originalStepIds.map(stepTechStepIds))).flat().sort()).to.deep.equal(
[chop, simmer, bake].sort(),
);
// Edit: step 1's description changes (chop -> mince), a brand new
// step 2 is added (-> melt), and the old steps 2/3 (simmer/bake) are
// dropped entirely — the three cases the recompute guarantee has to
// cover (see StepTechStep's schema doc comment).
const editedDescription = "Émincer les tomates";
const addedDescription = "Faire fondre le beurre";
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Ragoût",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }, { description: addedDescription }],
});
expect(res.status).to.equal(200);
expect(res.body.steps).to.have.length(2);
expect(
res.body.steps.map((s: { techSteps: { techStep: { key: string } }[] }) =>
s.techSteps.map((t) => t.techStep.key),
),
).to.deep.equal([["mince"], ["melt"]]);
// Modified step's span reflects the NEW text, not a stale one from
// "Hacher les oignons" (which doesn't even contain "émincer").
const editedTechStep = res.body.steps[0].techSteps[0];
expect(
editedDescription.slice(editedTechStep.start, editedTechStep.end).toLowerCase(),
).to.equal("émincer");
const addedTechStep = res.body.steps[1].techSteps[0];
expect(addedDescription.slice(addedTechStep.start, addedTechStep.end).toLowerCase()).to.equal(
"faire fondre",
);
// The dropped steps' old rows are actually gone (cascade), not just
// invisible in the response — confirms "delete" really deletes rather
// than orphaning StepTechStep rows nothing references any more.
const remainingSteps = await prisma.step.findMany({ where: { recipeId: created.body.id } });
expect(remainingSteps).to.have.length(2);
const orphanedTechSteps = await prisma.stepTechStep.findMany({
where: { stepId: { in: originalStepIds } },
});
expect(orphanedTechSteps).to.deep.equal([]);
});
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.patch("/recipes/999999").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
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("tomato");
const piece = await unitId("piece");
const created = await agent.post("/recipes").send({
name: "Salade",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade",
portions: 4,
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
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("tomato");
const piece = await unitId("piece");
const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
});
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
name: "Hack",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
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, portions: 4 },
});
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, portions: 4 },
});
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,
portions: 4,
},
});
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", portions: 4 },
});
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", portions: 4 },
});
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", portions: 4 },
});
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, portions: 4 },
});
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);
});
});
});