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>
369 lines
13 KiB
TypeScript
369 lines
13 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,
|
|
]);
|
|
});
|
|
|
|
it("leaves a leftovers item out of the cooking plan — it has nothing to cook (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 recipe = await prisma.recipe.create({
|
|
data: {
|
|
name: "Soupe",
|
|
authorId: houseRes.body.adminId,
|
|
portions: 4,
|
|
steps: { create: [{ order: 0, description: "Faire mijoter la soupe." }] },
|
|
},
|
|
});
|
|
|
|
// One real slot (12 prepared for 10 requested), then a leftovers slot
|
|
// reusing 2 of it — the plan must still contain only "Soupe" once.
|
|
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("/cooking-session").query({ date: today() });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.deep.equal(["Soupe"]);
|
|
});
|
|
});
|
|
});
|