PR 2/4 du chantier « gestion des restes » (issue #34). Deux nouvelles routes sur `/planning` : - `POST /planning/items/leftover` → `assignLeftoverPlanningItem`, 201. Crée un item « restes » (`sourcePlanningItemId` non nul) sur un créneau, même recette que la source (`recipeId` copié, jamais envoyé par le client). `portions` revérifié serveur contre le pool réellement disponible via `computeAvailableLeftover` → 409 `LEFTOVER_QUANTITY_EXCEEDED` (nouveau code, 4024) si dépassé ; 404 `PLANNING_ITEM_NOT_FOUND` si source inconnue / d'un autre foyer / elle- même un item « restes » (pas de restes-sur-restes). - `PATCH /planning/items/:id/discard-leftover` → `discardLeftover`, 200. Jette tout le pool restant (`discardedPortions += disponible`) ; idempotent (rien à jeter → renvoyé inchangé, aucun évènement). Exclusion : `shopping-list.service.ts` et `cooking-session.service.ts` filtrent `items: { where: { sourcePlanningItemId: null } }` dans leur `include` — un créneau qui ne fait que resservir un plat déjà cuisiné n'a rien à acheter ni à cuisiner. `getPlanningForDate` ne filtre pas (la grille affiche ces items). Analytics : `planning.leftover_assigned` / `planning.leftover_discarded`. Le mapping `PlanningItem` → `PlanningItemView` est factorisé (`toPlanningItemView`) — 4 chemins le partagent désormais. Tests (Mocha, DB) : `assignLeftoverPlanningItem` (succès + `recipeId` copié, 409 sur dépassement, 404 source étrangère/inconnue/« restes », cascade à la suppression de la source) ; `discardLeftover` (jette le disponible, idempotent, ne jette pas ce qui est déjà servi, 404) ; non-régression liste de courses et session de cuisine ignorant les items « restes ». Réf #34 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
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<number> {
|
||
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<number> {
|
||
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,
|
||
portionsRequested: 4,
|
||
},
|
||
{
|
||
planningId: planning.id,
|
||
weekDay: "mercredi",
|
||
meal: "diner",
|
||
recipeId: recipe.id,
|
||
portions: 4,
|
||
portionsRequested: 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("ignores a leftovers item — nothing new to buy for a slot that just reserves an already-cooked batch (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 authorId: number = houseRes.body.adminId;
|
||
|
||
const tomatoId = await ingredientId("tomato");
|
||
const gramId = await unitId("gram");
|
||
const recipe = await prisma.recipe.create({
|
||
data: {
|
||
name: "Salade de tomates",
|
||
authorId,
|
||
portions: 4,
|
||
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
|
||
},
|
||
});
|
||
|
||
// One real slot at 12 prepared (10 requested), then a leftovers slot
|
||
// reusing 2 of that batch — the shopping list must still be 300g
|
||
// (100 × 12/4), not 350g.
|
||
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("/shopping-list").query({ date: today() });
|
||
|
||
expect(res.status).to.equal(200);
|
||
expect(res.body.items).to.have.length(1);
|
||
expect(res.body.items[0].quantity).to.equal(300);
|
||
});
|
||
|
||
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,
|
||
portionsRequested: 4,
|
||
},
|
||
{
|
||
planningId: planning.id,
|
||
weekDay: "mardi",
|
||
meal: "diner",
|
||
recipeId: recipeB.id,
|
||
portions: 4,
|
||
portionsRequested: 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,
|
||
portionsRequested: 2,
|
||
},
|
||
{
|
||
planningId: planning.id,
|
||
weekDay: "mardi",
|
||
meal: "diner",
|
||
recipeId: recipeB.id,
|
||
portions: 2,
|
||
portionsRequested: 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,
|
||
portionsRequested: 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([]);
|
||
});
|
||
});
|
||
});
|