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-sources/recipe-source-adapter.js"; import { clearRecipeSources, registerRecipeSource, } from "../src/lib/recipe-sources/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 { 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 { 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 { 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/recipe-matching/tech-step-matcher.js`) so tests can assert on it directly. */ async function stepTechStepIds(stepId: number): Promise { 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; 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 { 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); }); }); });