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 /shopping-list`'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; } /** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helper as `recipe.test.ts`. */ 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 — same helper as `recipe.test.ts`. */ async function unitId(key: string): Promise { const unit = await prisma.unit.findFirstOrThrow({ where: { key } }); return unit.id; } describe("Shopping list", () => { const app = createApp(); beforeEach(async () => { await resetDatabase(); }); after(async () => { await prisma.$disconnect(); }); describe("GET /shopping-list", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).get("/shopping-list").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("/shopping-list"); 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("/shopping-list").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("/shopping-list").query({ date: "2026-02-30" }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("returns an empty list when the profile has no household", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const res = await agent.get("/shopping-list").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.items).to.deep.equal([]); }); it("returns an empty list when the household has no planning covering that date", 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("/shopping-list").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.items).to.deep.equal([]); }); it("sums one recipe's ingredient across two planning slots, scaled by each slot's own portions", 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 tomatoId = await ingredientId("tomato"); const gramId = await unitId("gram"); // Written for 2 portions, 100g tomato — planned twice this week at // 4 portions each, so the shopping list should show 100 × (4/2) × 2 // = 400g, not the raw 200g the recipe itself lists. const recipe = await prisma.recipe.create({ data: { name: "Salade de tomates", authorId, portions: 2, ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] }, }, }); 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: recipe.id, portions: 4, }, { planningId: planning.id, weekDay: "mercredi", meal: "diner", recipeId: recipe.id, portions: 4, }, ], }); const res = await agent.get("/shopping-list").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.items).to.have.length(1); expect(res.body.items[0].ingredient.key).to.equal("tomato"); expect(res.body.items[0].unit.key).to.equal("gram"); expect(res.body.items[0].quantity).to.equal(400); }); it("sums the same ingredient across two different recipes sharing a unit", 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 gramId = await unitId("gram"); const recipeA = await prisma.recipe.create({ data: { name: "Soupe à l'oignon", authorId, portions: 4, ingredients: { create: [{ ingredientId: onionId, quantity: 200, unitId: gramId }] }, }, }); const recipeB = await prisma.recipe.create({ data: { name: "Tarte à l'oignon", authorId, portions: 4, ingredients: { create: [{ ingredientId: onionId, quantity: 150, unitId: gramId }] }, }, }); 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: recipeA.id, portions: 4, }, { planningId: planning.id, weekDay: "mardi", meal: "diner", recipeId: recipeB.id, portions: 4, }, ], }); const res = await agent.get("/shopping-list").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.items).to.have.length(1); expect(res.body.items[0].ingredient.key).to.equal("onion"); expect(res.body.items[0].quantity).to.equal(350); }); it("keeps the same ingredient in two different units as two separate lines", 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 tomatoId = await ingredientId("tomato"); const gramId = await unitId("gram"); const kilogramId = await unitId("kilogram"); const recipeA = await prisma.recipe.create({ data: { name: "Recette A", authorId, portions: 2, ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] }, }, }); const recipeB = await prisma.recipe.create({ data: { name: "Recette B", authorId, portions: 2, ingredients: { create: [{ ingredientId: tomatoId, quantity: 1, unitId: kilogramId }] }, }, }); 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: recipeA.id, portions: 2, }, { planningId: planning.id, weekDay: "mardi", meal: "diner", recipeId: recipeB.id, portions: 2, }, ], }); const res = await agent.get("/shopping-list").query({ date: today() }); expect(res.status).to.equal(200); expect(res.body.items).to.have.length(2); const units = res.body.items.map((item: { unit: { key: string } }) => item.unit.key).sort(); expect(units).to.deep.equal(["gram", "kilogram"]); }); it("returns a different week's shopping list 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 authorId: number = houseRes.body.adminId; const tomatoId = await ingredientId("tomato"); const gramId = await unitId("gram"); const recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles", authorId, portions: 2, ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] }, }, }); 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, }, }); const nextWeekRes = await agent.get("/shopping-list").query({ date: isoDate(nextWeek) }); expect(nextWeekRes.body.items).to.have.length(1); const thisWeekRes = await agent.get("/shopping-list").query({ date: today() }); expect(thisWeekRes.body.items).to.deep.equal([]); }); }); });