import type { DateTime } from "@batch-cooking/date-tools"; import { ErrorCode, type SignupInput } 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 { TEST_REFERENCE_DATE } from "../test-support/reference-date.js"; import { resetDatabase } from "../test-support/reset-db.js"; /** See `auth.test.ts` — same rationale for generating rather than hardcoding. */ 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 }), }; } /** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */ function isoDate(date: DateTime): string { const iso = date.toISODate(); if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper"); return iso; } /** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */ function today(): string { return isoDate(TEST_REFERENCE_DATE); } /** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */ async function ingredientId(key: string): Promise { return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id; } async function unitId(key: string): Promise { return (await prisma.unit.findFirstOrThrow({ where: { key } })).id; } async function techStepId(key: string): Promise { return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id; } describe("Cooking session", () => { const app = createApp(); beforeEach(async () => { await resetDatabase(); }); after(async () => { await prisma.$disconnect(); }); describe("GET /cooking-session", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).get("/cooking-session").query({ date: today() }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects a malformed date with 400 VALIDATION_ERROR", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const res = await agent.get("/cooking-session").query({ date: "not-a-date" }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("returns an empty plan when the profile has no household", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const res = await agent.get("/cooking-session").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.recipes).to.deep.equal([]); expect(res.body.phases).to.deep.equal([]); }); it("returns an empty plan when no planning covers that week", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/house").send({ name: "Chez moi" }); const res = await agent.get("/cooking-session").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.phases).to.deep.equal([]); }); it("pools an identical prep step from two planned recipes into one merged-prep task", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseId: number = houseRes.body.id; const authorId: number = houseRes.body.adminId; const onionId = await ingredientId("onion"); const pieceId = await unitId("piece"); const chopId = await techStepId("chop"); const simmerId = await techStepId("simmer"); const mixId = await techStepId("mix"); /** * A recipe: one pure-prep "chop onion" step, then (optionally) an * active "mix" step, then one simmer step. The `withActiveStep` recipe * is still doing hands-on work in the phase after its simmer starts, so * the other recipe's simmer floats into that phase as `background`. */ async function makeRecipe(name: string, onionQty: number, withActiveStep = false) { const chopStep = { order: 0, description: "Émincer les oignons", techSteps: { create: [ { techStepId: chopId, order: 0, ingredients: { create: [ { ingredientId: onionId, quantity: onionQty, unitId: pieceId, start: 0, end: 1, }, ], }, }, ], }, }; const activeStep = { order: 1, description: "Mélanger l'appareil", techSteps: { create: [{ techStepId: mixId, order: 0 }] }, }; const simmerStep = { order: withActiveStep ? 2 : 1, description: "Faire mijoter", techSteps: { create: [{ techStepId: simmerId, order: 0 }] }, }; return prisma.recipe.create({ data: { name, authorId, portions: 4, steps: { create: withActiveStep ? [chopStep, activeStep, simmerStep] : [chopStep, simmerStep], }, }, }); } const soupe = await makeRecipe("Soupe", 2); const tarte = await makeRecipe("Tarte", 3, true); const planning = await prisma.planning.create({ data: { houseId, startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(), finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(), }, }); await prisma.planningItem.createMany({ data: [ { planningId: planning.id, weekDay: "lundi", meal: "dejeuner", recipeId: soupe.id, portions: 4, portionsRequested: 4, }, { planningId: planning.id, weekDay: "mardi", meal: "diner", recipeId: tarte.id, portions: 4, portionsRequested: 4, }, ], }); const res = await agent.get("/cooking-session").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([ "Soupe", "Tarte", ]); const mise = res.body.phases[0]; expect(mise.kind).to.equal("mise-en-place"); const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep"); expect(merged).to.have.length(1); expect(merged[0].technique.key).to.equal("chop"); expect(merged[0].ingredients[0].ingredient.key).to.equal("onion"); expect(merged[0].ingredients[0].quantity).to.equal(5); expect(merged[0].sourceRecipes).to.have.length(2); // The simmer steps land in a later phase, and one shows as background. const later = res.body.phases.slice(1); const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background); expect(backgrounds.length).to.be.greaterThan(0); }); it("pools two preheats at the same temperature, and flags a conflict at different temperatures", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseId: number = houseRes.body.id; const authorId: number = houseRes.body.adminId; const preheatId = await techStepId("preheat"); const bakeId = await techStepId("bake"); /** A recipe whose first step is "préchauffer le four" carrying a °C temperature, then a bake. */ async function makeOvenRecipe(name: string, description: string, celsius: number) { return prisma.recipe.create({ data: { name, authorId, portions: 4, steps: { create: [ { order: 0, description, techSteps: { create: [ { techStepId: preheatId, order: 0, temperatures: { create: [ { start: 0, end: description.length, celsius, gasMark: null, qualitative: null, raw: `${celsius}°C`, }, ], }, }, ], }, }, { order: 1, description: "Enfourner", techSteps: { create: [{ techStepId: bakeId, order: 0 }] }, }, ], }, }, }); } async function planWeek(recipeIds: number[]) { const planning = await prisma.planning.create({ data: { houseId, startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(), finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(), }, }); await prisma.planningItem.createMany({ data: recipeIds.map((recipeId, i) => ({ planningId: planning.id, weekDay: i === 0 ? "lundi" : "mardi", meal: i === 0 ? "dejeuner" : "diner", recipeId, portions: 4, portionsRequested: 4, })), }); } // Same temperature → one pooled merged-setup task, no conflict note. const gratin = await makeOvenRecipe("Gratin", "Préchauffer le four à 180°C", 180); const tarte = await makeOvenRecipe("Tarte", "Préchauffer à 180 degrés", 180); await planWeek([gratin.id, tarte.id]); let res = await agent.get("/cooking-session").query({ date: today() }); expect(res.status).to.equal(200); let mise = res.body.phases[0]; const setupMerges = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup"); expect(setupMerges).to.have.length(1); expect(setupMerges[0].technique.key).to.equal("preheat"); expect(setupMerges[0].temperatures).to.deep.equal([ { celsius: 180, gasMark: null, qualitative: null }, ]); expect(mise.notes).to.deep.equal([]); // Re-plan the same week with a second temperature → not merged, conflict note. await prisma.planning.deleteMany({ where: { houseId } }); const roti = await makeOvenRecipe("Rôti", "Préchauffer le four à 210°C", 210); await planWeek([gratin.id, roti.id]); res = await agent.get("/cooking-session").query({ date: today() }); mise = res.body.phases[0]; expect(mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup")).to.have.length( 0, ); const preheats = mise.tasks.filter( (t: { technique: { key: string } | null }) => t.technique?.key === "preheat", ); expect(preheats.map((t: { description: string }) => t.description)).to.deep.equal([ "Préchauffer le four à 180°C", "Préchauffer le four à 210°C", ]); const note = mise.notes.find( (n: { kind: string }) => n.kind === "appliance-temperature-conflict", ); expect(note, "a conflict note").to.not.equal(undefined); expect(note.temperatures.map((tp: { celsius: number }) => tp.celsius)).to.deep.equal([ 180, 210, ]); }); it("leaves a leftovers item out of the cooking plan — it has nothing to cook (issue #34)", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const recipe = await prisma.recipe.create({ data: { name: "Soupe", authorId: houseRes.body.adminId, portions: 4, steps: { create: [{ order: 0, description: "Faire mijoter la soupe." }] }, }, }); // One real slot (12 prepared for 10 requested), then a leftovers slot // reusing 2 of it — the plan must still contain only "Soupe" once. const source = await agent.post("/planning/items").send({ date: today(), weekDay: "lundi", meal: "diner", recipeId: recipe.id, portionsRequested: 10, }); await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mardi", meal: "dejeuner", sourceItemId: source.body.id, portions: 2, }); const res = await agent.get("/cooking-session").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.recipes.map((r: { name: string }) => r.name)).to.deep.equal(["Soupe"]); }); }); });