batchCooking/apps/api/test/shopping-list.test.ts
Nicolas eba0ea70bd
All checks were successful
CI / lint (push) Successful in 2m37s
CI / intent-service-test (push) Successful in 4m26s
CI / test (push) Successful in 15m8s
CI / build (push) Successful in 2m45s
CI / e2e (push) Successful in 8m6s
feat(planning): sépare portions demandées et portions préparées
Première PR du chantier « gestion des restes » (issue #34) — les
fondations, sans encore d'UI de panier.

À l'ajout d'une recette au planning on ne saisit plus qu'un seul nombre,
`portionsRequested` (« combien ce créneau doit nourrir »). Le serveur en
dérive `portions` (ce qui est réellement préparé) via
`computeBatchedPortions` : arrondi au multiple supérieur du rendement de
la recette (on cuisine des lots entiers, jamais une fraction de plat).
L'écart est le futur pool de restes de l'item.

Modèle (`PlanningItem`, purement additif) :
- `portionsRequested` NOT NULL — migration nullable → backfill (`= portions`
  pour les lignes existantes, sans restes à l'époque) → verrou, même
  discipline que `ingredient_unit_catalog`.
- `sourcePlanningItemId` (auto-FK nullable, `onDelete: Cascade`) et
  `discardedPortions` (défaut 0) — posés maintenant, utilisés plus tard.

Partagé (`@batch-cooking/shared/logic/planning-portions.ts`) :
- `computeBatchedPortions(recipePortions, requested)` → `{ portions, leftover }`
- `computeAvailableLeftover(item)` → pool encore disponible (posé pour les
  PRs suivantes : validation d'écriture côté API, affichage du panier
  côté web — une seule formule, deux appelants)

API : `addPlanningItemSchema` prend `portionsRequested` ; `addPlanningItem`
calcule et stocke les deux valeurs + l'évènement analytics ; `PlanningItemView`
et `getPlanningForDate` exposent `portionsRequested`/`discardedPortions`/
`sourceItem`.

Front : `RecipePickerDialog` — champ « portions demandées » + indice en
direct « → N préparées, M en restes » quand l'arrondi crée un reste ;
envoie `portionsRequested`. `RecipeImportForm` — envoie `portionsRequested`
(un import atterrit à son propre rendement, donc reste nul).

Tests : `planning-portions.test.ts` (les deux exemples de #34 + bornes) ;
nouveau bloc `POST /planning/items` dans `planning.test.ts` (arrondi au
lot, plusieurs lots, borne exacte, 400 sur non-positif) ; fixtures
`planningItem.create` des suites existantes mises à jour ; scénario
Cypress planning ajusté (`portionsRequested` dans le corps).

Docs : `batch-cooking-modele.md`, `backend-architecture.md`,
`frontend-architecture.md`.

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:29:58 +02:00

343 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("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([]);
});
});
});