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 }), }; } /** The fixed test "today", as the `YYYY-MM-DD` string `GET /planning`'s `?date=` expects. */ function today(): string { return isoDate(TEST_REFERENCE_DATE); } /** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */ function isoDate(date: DateTime): string { const iso = date.toISODate(); if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper"); return iso; } describe("Planning", () => { const app = createApp(); beforeEach(async () => { await resetDatabase(); }); after(async () => { await prisma.$disconnect(); }); describe("GET /planning", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).get("/planning").query({ date: today() }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects a missing date with 400 VALIDATION_ERROR", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const res = await agent.get("/planning"); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); 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("/planning").query({ date: "not-a-date" }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("rejects a date shaped right but calendarially impossible with 400 VALIDATION_ERROR", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const res = await agent.get("/planning").query({ date: "2026-02-30" }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("returns null when the household has no planning covering that date", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const res = await agent.get("/planning").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body).to.equal(null); }); it("returns the household's planning covering that date, with recipes resolved", 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 recipe = await prisma.recipe.create({ data: { name: "Ratatouille", authorId: houseRes.body.adminId, portions: 4 }, }); 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.create({ data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id, portions: 4, portionsRequested: 3, }, }); const res = await agent.get("/planning").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.id).to.equal(planning.id); expect(res.body.items).to.have.length(1); expect(res.body.items[0]).to.include({ weekDay: "lundi", meal: "diner", portions: 4, portionsRequested: 3, discardedPortions: 0, }); expect(res.body.items[0].sourceItem).to.equal(null); expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" }); }); it("returns null when the household's planning does not cover that date", 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; // A planning entirely in the past — shouldn't be picked up for today. await prisma.planning.create({ data: { houseId, startDate: new Date(Date.UTC(2000, 0, 1)), finishDate: new Date(Date.UTC(2000, 0, 7)), }, }); const res = await agent.get("/planning").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body).to.equal(null); }); it("returns a different week's planning when asked for a date outside the current one", 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 recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles", authorId: houseRes.body.adminId, portions: 4 }, }); const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 }); const planning = await prisma.planning.create({ data: { houseId, startDate: nextWeek.startOf("week").toJSDate(), finishDate: nextWeek.endOf("week").startOf("day").toJSDate(), }, }); await prisma.planningItem.create({ data: { planningId: planning.id, weekDay: "mardi", meal: "dejeuner", recipeId: recipe.id, portions: 2, portionsRequested: 2, }, }); const res = await agent.get("/planning").query({ date: isoDate(nextWeek) }); expect(res.status).to.equal(200); expect(res.body.id).to.equal(planning.id); const thisWeekRes = await agent.get("/planning").query({ date: today() }); expect(thisWeekRes.body).to.equal(null); }); }); describe("POST /planning/items", () => { /** Signs up, creates a household, and returns an authenticated agent + a saved recipe with the given yield. */ async function setup(recipePortions: number) { 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: "Lasagnes", authorId: houseRes.body.adminId, portions: recipePortions }, }); return { agent, recipe }; } it("rounds the prepared portions up to a whole batch of the recipe and reports the leftover", async () => { const { agent, recipe } = await setup(4); const res = await agent.post("/planning/items").send({ date: today(), weekDay: "lundi", meal: "diner", recipeId: recipe.id, portionsRequested: 3, }); expect(res.status).to.equal(201); // 3 requested, 4-portion recipe -> one batch of 4 prepared, 1 leftover. expect(res.body).to.include({ portions: 4, portionsRequested: 3, discardedPortions: 0 }); expect(res.body.sourceItem).to.equal(null); const stored = await prisma.planningItem.findUnique({ where: { id: res.body.id } }); expect(stored).to.include({ portions: 4, portionsRequested: 3 }); }); it("cooks several batches when more portions are requested than one batch yields", async () => { const { agent, recipe } = await setup(4); const res = await agent.post("/planning/items").send({ date: today(), weekDay: "mardi", meal: "dejeuner", recipeId: recipe.id, portionsRequested: 10, }); expect(res.status).to.equal(201); // 10 requested, 4-portion recipe -> three batches (12) prepared, 2 leftover. expect(res.body).to.include({ portions: 12, portionsRequested: 10 }); }); it("leaves no leftover when the request lands exactly on a batch boundary", async () => { const { agent, recipe } = await setup(4); const res = await agent.post("/planning/items").send({ date: today(), weekDay: "mercredi", meal: "diner", recipeId: recipe.id, portionsRequested: 8, }); expect(res.status).to.equal(201); expect(res.body).to.include({ portions: 8, portionsRequested: 8 }); }); it("rejects a non-positive portionsRequested with 400 VALIDATION_ERROR", async () => { const { agent, recipe } = await setup(4); const res = await agent.post("/planning/items").send({ date: today(), weekDay: "lundi", meal: "diner", recipeId: recipe.id, portionsRequested: 0, }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); }); describe("leftovers — POST /planning/items/leftover and PATCH .../discard-leftover", () => { /** * Signs up + creates a household, saves a 4-portion recipe, and plans * it once with `requested` portions — so the resulting item has a * leftover pool of `ceil(requested/4)*4 - requested`. Returns the agent, * the recipe, and that source item. */ async function setupWithSource(requested: number) { 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: "Lasagnes", authorId: houseRes.body.adminId, portions: 4 }, }); const addRes = await agent.post("/planning/items").send({ date: today(), weekDay: "lundi", meal: "diner", recipeId: recipe.id, portionsRequested: requested, }); return { agent, recipe, source: addRes.body }; } it("serves part of a source item's leftover pool as a new leftovers item on another slot", async () => { // 10 requested on a 4-portion recipe -> 12 prepared, pool of 2. const { agent, recipe, source } = await setupWithSource(10); expect(source.portions).to.equal(12); const res = await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mardi", meal: "dejeuner", sourceItemId: source.id, portions: 2, }); expect(res.status).to.equal(201); expect(res.body).to.include({ weekDay: "mardi", meal: "dejeuner", portions: 2, portionsRequested: 2, discardedPortions: 0, }); expect(res.body.sourceItem).to.deep.equal({ id: source.id }); // The recipe is copied from the source, not sent by the client. expect(res.body.recipe.id).to.equal(recipe.id); }); it("rejects assigning more portions than the source pool still has, with 409 LEFTOVER_QUANTITY_EXCEEDED", async () => { const { agent, source } = await setupWithSource(10); // pool of 2 // Take 2 of the 2 available... await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mardi", meal: "dejeuner", sourceItemId: source.id, portions: 2, }); // ...then ask for 1 more — nothing left. const res = await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mercredi", meal: "diner", sourceItemId: source.id, portions: 1, }); expect(res.status).to.equal(409); expect(res.body.code).to.equal(ErrorCode.LEFTOVER_QUANTITY_EXCEEDED); }); it("404s when the source item belongs to another household", async () => { const { source } = await setupWithSource(10); const stranger = request.agent(app); await stranger.post("/auth/signup").send(buildSignupPayload()); await stranger.post("/house").send({ name: "Ailleurs" }); const res = await stranger.post("/planning/items/leftover").send({ date: today(), weekDay: "mardi", meal: "dejeuner", sourceItemId: source.id, portions: 1, }); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND); }); it("404s when the source item is itself a leftovers item (no leftovers-on-leftovers)", async () => { const { agent, source } = await setupWithSource(10); const leftover = await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mardi", meal: "dejeuner", sourceItemId: source.id, portions: 1, }); const res = await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mercredi", meal: "diner", sourceItemId: leftover.body.id, portions: 1, }); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND); }); it("deleting the source item cascades to every leftovers item served from it", async () => { const { agent, source } = await setupWithSource(10); const leftover = await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mardi", meal: "dejeuner", sourceItemId: source.id, portions: 2, }); await agent.delete(`/planning/items/${source.id}`).expect(204); const stillThere = await prisma.planningItem.findUnique({ where: { id: leftover.body.id } }); expect(stillThere).to.equal(null); }); it("discards the whole remaining pool, and is idempotent", async () => { const { agent, source } = await setupWithSource(10); // pool of 2 const first = await agent.patch(`/planning/items/${source.id}/discard-leftover`); expect(first.status).to.equal(200); expect(first.body.discardedPortions).to.equal(2); // Nothing left — a second call changes nothing. const second = await agent.patch(`/planning/items/${source.id}/discard-leftover`); expect(second.status).to.equal(200); expect(second.body.discardedPortions).to.equal(2); // And the pool is now empty for an assignment too. const assign = await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mardi", meal: "dejeuner", sourceItemId: source.id, portions: 1, }); expect(assign.status).to.equal(409); }); it("only discards what's still available after some was already served", async () => { const { agent, source } = await setupWithSource(10); // pool of 2 await agent.post("/planning/items/leftover").send({ date: today(), weekDay: "mardi", meal: "dejeuner", sourceItemId: source.id, portions: 1, }); const res = await agent.patch(`/planning/items/${source.id}/discard-leftover`); expect(res.status).to.equal(200); // 1 of the 2 was served; only the other 1 gets discarded. expect(res.body.discardedPortions).to.equal(1); }); it("404s discarding an unknown planning item", async () => { const { agent } = await setupWithSource(4); const res = await agent.patch("/planning/items/999999/discard-leftover"); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND); }); }); });