batchCooking/apps/api/test/planning.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

274 lines
9.7 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 /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);
});
});
});