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>
332 lines
12 KiB
TypeScript
332 lines
12 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 }),
|
|
};
|
|
}
|
|
|
|
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
|
|
function isoDate(date: DateTime): string {
|
|
const iso = date.toISODate();
|
|
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
|
|
return iso;
|
|
}
|
|
|
|
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
|
|
function today(): string {
|
|
return isoDate(TEST_REFERENCE_DATE);
|
|
}
|
|
|
|
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
|
|
async function ingredientId(key: string): Promise<number> {
|
|
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
|
|
}
|
|
async function unitId(key: string): Promise<number> {
|
|
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
|
|
}
|
|
async function techStepId(key: string): Promise<number> {
|
|
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
|
|
}
|
|
|
|
describe("Cooking session", () => {
|
|
const app = createApp();
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("GET /cooking-session", () => {
|
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
const res = await request(app).get("/cooking-session").query({ date: today() });
|
|
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
});
|
|
|
|
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("/cooking-session").query({ date: "not-a-date" });
|
|
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
});
|
|
|
|
it("returns an empty plan when the profile has no household", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent.get("/cooking-session").query({ date: today() });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.recipes).to.deep.equal([]);
|
|
expect(res.body.phases).to.deep.equal([]);
|
|
});
|
|
|
|
it("returns an empty plan when no planning covers that week", 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("/cooking-session").query({ date: today() });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.phases).to.deep.equal([]);
|
|
});
|
|
|
|
it("pools an identical prep step from two planned recipes into one merged-prep task", 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 pieceId = await unitId("piece");
|
|
const chopId = await techStepId("chop");
|
|
const simmerId = await techStepId("simmer");
|
|
const mixId = await techStepId("mix");
|
|
|
|
/**
|
|
* A recipe: one pure-prep "chop onion" step, then (optionally) an
|
|
* active "mix" step, then one simmer step. The `withActiveStep` recipe
|
|
* is still doing hands-on work in the phase after its simmer starts, so
|
|
* the other recipe's simmer floats into that phase as `background`.
|
|
*/
|
|
async function makeRecipe(name: string, onionQty: number, withActiveStep = false) {
|
|
const chopStep = {
|
|
order: 0,
|
|
description: "Émincer les oignons",
|
|
techSteps: {
|
|
create: [
|
|
{
|
|
techStepId: chopId,
|
|
order: 0,
|
|
ingredients: {
|
|
create: [
|
|
{
|
|
ingredientId: onionId,
|
|
quantity: onionQty,
|
|
unitId: pieceId,
|
|
start: 0,
|
|
end: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const activeStep = {
|
|
order: 1,
|
|
description: "Mélanger l'appareil",
|
|
techSteps: { create: [{ techStepId: mixId, order: 0 }] },
|
|
};
|
|
const simmerStep = {
|
|
order: withActiveStep ? 2 : 1,
|
|
description: "Faire mijoter",
|
|
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
|
|
};
|
|
return prisma.recipe.create({
|
|
data: {
|
|
name,
|
|
authorId,
|
|
portions: 4,
|
|
steps: {
|
|
create: withActiveStep ? [chopStep, activeStep, simmerStep] : [chopStep, simmerStep],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const soupe = await makeRecipe("Soupe", 2);
|
|
const tarte = await makeRecipe("Tarte", 3, true);
|
|
|
|
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: soupe.id,
|
|
portions: 4,
|
|
portionsRequested: 4,
|
|
},
|
|
{
|
|
planningId: planning.id,
|
|
weekDay: "mardi",
|
|
meal: "diner",
|
|
recipeId: tarte.id,
|
|
portions: 4,
|
|
portionsRequested: 4,
|
|
},
|
|
],
|
|
});
|
|
|
|
const res = await agent.get("/cooking-session").query({ date: today() });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
|
|
"Soupe",
|
|
"Tarte",
|
|
]);
|
|
|
|
const mise = res.body.phases[0];
|
|
expect(mise.kind).to.equal("mise-en-place");
|
|
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
|
|
expect(merged).to.have.length(1);
|
|
expect(merged[0].technique.key).to.equal("chop");
|
|
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
|
|
expect(merged[0].ingredients[0].quantity).to.equal(5);
|
|
expect(merged[0].sourceRecipes).to.have.length(2);
|
|
|
|
// The simmer steps land in a later phase, and one shows as background.
|
|
const later = res.body.phases.slice(1);
|
|
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
|
|
expect(backgrounds.length).to.be.greaterThan(0);
|
|
});
|
|
|
|
it("pools two preheats at the same temperature, and flags a conflict at different temperatures", 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 preheatId = await techStepId("preheat");
|
|
const bakeId = await techStepId("bake");
|
|
|
|
/** A recipe whose first step is "préchauffer le four" carrying a °C temperature, then a bake. */
|
|
async function makeOvenRecipe(name: string, description: string, celsius: number) {
|
|
return prisma.recipe.create({
|
|
data: {
|
|
name,
|
|
authorId,
|
|
portions: 4,
|
|
steps: {
|
|
create: [
|
|
{
|
|
order: 0,
|
|
description,
|
|
techSteps: {
|
|
create: [
|
|
{
|
|
techStepId: preheatId,
|
|
order: 0,
|
|
temperatures: {
|
|
create: [
|
|
{
|
|
start: 0,
|
|
end: description.length,
|
|
celsius,
|
|
gasMark: null,
|
|
qualitative: null,
|
|
raw: `${celsius}°C`,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
order: 1,
|
|
description: "Enfourner",
|
|
techSteps: { create: [{ techStepId: bakeId, order: 0 }] },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function planWeek(recipeIds: number[]) {
|
|
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: recipeIds.map((recipeId, i) => ({
|
|
planningId: planning.id,
|
|
weekDay: i === 0 ? "lundi" : "mardi",
|
|
meal: i === 0 ? "dejeuner" : "diner",
|
|
recipeId,
|
|
portions: 4,
|
|
portionsRequested: 4,
|
|
})),
|
|
});
|
|
}
|
|
|
|
// Same temperature → one pooled merged-setup task, no conflict note.
|
|
const gratin = await makeOvenRecipe("Gratin", "Préchauffer le four à 180°C", 180);
|
|
const tarte = await makeOvenRecipe("Tarte", "Préchauffer à 180 degrés", 180);
|
|
await planWeek([gratin.id, tarte.id]);
|
|
|
|
let res = await agent.get("/cooking-session").query({ date: today() });
|
|
expect(res.status).to.equal(200);
|
|
let mise = res.body.phases[0];
|
|
const setupMerges = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup");
|
|
expect(setupMerges).to.have.length(1);
|
|
expect(setupMerges[0].technique.key).to.equal("preheat");
|
|
expect(setupMerges[0].temperatures).to.deep.equal([
|
|
{ celsius: 180, gasMark: null, qualitative: null },
|
|
]);
|
|
expect(mise.notes).to.deep.equal([]);
|
|
|
|
// Re-plan the same week with a second temperature → not merged, conflict note.
|
|
await prisma.planning.deleteMany({ where: { houseId } });
|
|
const roti = await makeOvenRecipe("Rôti", "Préchauffer le four à 210°C", 210);
|
|
await planWeek([gratin.id, roti.id]);
|
|
|
|
res = await agent.get("/cooking-session").query({ date: today() });
|
|
mise = res.body.phases[0];
|
|
expect(mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup")).to.have.length(
|
|
0,
|
|
);
|
|
const preheats = mise.tasks.filter(
|
|
(t: { technique: { key: string } | null }) => t.technique?.key === "preheat",
|
|
);
|
|
expect(preheats.map((t: { description: string }) => t.description)).to.deep.equal([
|
|
"Préchauffer le four à 180°C",
|
|
"Préchauffer le four à 210°C",
|
|
]);
|
|
const note = mise.notes.find(
|
|
(n: { kind: string }) => n.kind === "appliance-temperature-conflict",
|
|
);
|
|
expect(note, "a conflict note").to.not.equal(undefined);
|
|
expect(note.temperatures.map((tp: { celsius: number }) => tp.celsius)).to.deep.equal([
|
|
180, 210,
|
|
]);
|
|
});
|
|
});
|
|
});
|