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 { resetDatabase } from "../test-support/reset-db.js"; /** See `auth.test.ts` — generated, never a real-looking person. */ 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 unit's id by its seed uid (also its DB `key`). */ async function unitId(key: string): Promise { return (await prisma.unit.findFirstOrThrow({ where: { key } })).id; } /** Resolves a reference ingredient's id by its seed uid. */ async function ingredientId(key: string): Promise { return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id; } /** * The off-catalog ("placeholder") ingredient escape hatch on `POST /recipes` * / `PATCH /recipes/:id` — a line with `placeholderName` instead of * `ingredientId` creates a dedicated `Ingredient` row * (`isPlaceholder: true`) so the recipe still saves, and that row is * surfaced (with its `displayName`) inside the recipe but never in the * browsable catalog. */ describe("Recipes — off-catalog placeholder ingredients", () => { const app = createApp(); 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(); }); it("creates a placeholder Ingredient row for a `placeholderName` line and returns it inside the recipe", async () => { const { agent, profileId } = await signup(); const piece = await unitId("piece"); const tomato = await ingredientId("tomato"); const res = await agent.post("/recipes").send({ name: "Poulet basquaise", portions: 4, dietIds: [], ingredients: [ { ingredientId: tomato, quantity: 3, unitId: piece }, { placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece }, ], steps: [{ description: "Tout mélanger" }], }); expect(res.status).to.equal(201); expect(res.body.ingredients).to.have.length(2); const placeholderLine = res.body.ingredients.find( (line: { ingredient: { isPlaceholder: boolean } }) => line.ingredient.isPlaceholder, ); expect(placeholderLine, "a placeholder line is present").to.not.equal(undefined); expect(placeholderLine.ingredient.displayName).to.equal("Piment d'Espelette"); expect(placeholderLine.ingredient.key).to.match(/^placeholder:/); expect(placeholderLine.ingredient.allergens).to.deep.equal([]); expect(placeholderLine.quantity).to.equal(1); const row = await prisma.ingredient.findUniqueOrThrow({ where: { id: placeholderLine.ingredient.id }, }); expect(row.isPlaceholder).to.equal(true); expect(row.displayName).to.equal("Piment d'Espelette"); expect(row.createdById).to.equal(profileId); expect(row.createdAt).to.be.an.instanceOf(Date); expect(row.reviewedAt).to.equal(null); }); it("never lists placeholder rows in GET /reference/ingredients", async () => { const { agent } = await signup(); const piece = await unitId("piece"); await agent.post("/recipes").send({ name: "Test", portions: 2, dietIds: [], ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }], steps: [{ description: "x" }], }); const reference = await agent.get("/reference/ingredients"); expect(reference.status).to.equal(200); expect( reference.body.some( (i: { isPlaceholder?: boolean; displayName?: string }) => i.isPlaceholder === true || i.displayName === "Feuille de combava", ), ).to.equal(false); }); it("reuses the existing placeholder row on edit (no duplicate) and drops it when the line is removed", async () => { const { agent } = await signup(); const piece = await unitId("piece"); const tomato = await ingredientId("tomato"); const created = await agent.post("/recipes").send({ name: "Édition", portions: 2, dietIds: [], ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }], steps: [{ description: "x" }], }); const placeholderId = created.body.ingredients[0].ingredient.id; // Re-submit the same recipe, keeping the placeholder line by its real id. const edited = await agent.patch(`/recipes/${created.body.id}`).send({ name: "Édition", portions: 2, dietIds: [], ingredients: [ { ingredientId: placeholderId, quantity: 2, unitId: piece }, { ingredientId: tomato, quantity: 1, unitId: piece }, ], steps: [{ description: "x" }], }); expect(edited.status).to.equal(200); expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1); // Now edit again, dropping the placeholder line entirely. await agent.patch(`/recipes/${created.body.id}`).send({ name: "Édition", portions: 2, dietIds: [], ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }], steps: [{ description: "x" }], }); // The row is now an orphan (kept on purpose — the admin catalog view // prunes it), but no *new* placeholder was created. expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1); }); it("rejects a line carrying both ingredientId and placeholderName with 400", async () => { const { agent } = await signup(); const piece = await unitId("piece"); const tomato = await ingredientId("tomato"); const res = await agent.post("/recipes").send({ name: "Invalide", portions: 2, dietIds: [], ingredients: [ { ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece }, ], steps: [{ description: "x" }], }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("allows two placeholder lines with the same text (each becomes its own row)", async () => { const { agent } = await signup(); const piece = await unitId("piece"); const res = await agent.post("/recipes").send({ name: "Doublons libres", portions: 2, dietIds: [], ingredients: [ { placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece }, { placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece }, ], steps: [{ description: "x" }], }); expect(res.status).to.equal(201); expect(res.body.ingredients).to.have.length(2); expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(2); }); });