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>
452 lines
16 KiB
TypeScript
452 lines
16 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);
|
|
});
|
|
});
|
|
|
|
describe("leftovers — POST /planning/items/leftover and PATCH .../discard-leftover", () => {
|
|
/**
|
|
* Signs up + creates a household, saves a 4-portion recipe, and plans
|
|
* it once with `requested` portions — so the resulting item has a
|
|
* leftover pool of `ceil(requested/4)*4 - requested`. Returns the agent,
|
|
* the recipe, and that source item.
|
|
*/
|
|
async function setupWithSource(requested: 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: 4 },
|
|
});
|
|
const addRes = await agent.post("/planning/items").send({
|
|
date: today(),
|
|
weekDay: "lundi",
|
|
meal: "diner",
|
|
recipeId: recipe.id,
|
|
portionsRequested: requested,
|
|
});
|
|
return { agent, recipe, source: addRes.body };
|
|
}
|
|
|
|
it("serves part of a source item's leftover pool as a new leftovers item on another slot", async () => {
|
|
// 10 requested on a 4-portion recipe -> 12 prepared, pool of 2.
|
|
const { agent, recipe, source } = await setupWithSource(10);
|
|
expect(source.portions).to.equal(12);
|
|
|
|
const res = await agent.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
sourceItemId: source.id,
|
|
portions: 2,
|
|
});
|
|
|
|
expect(res.status).to.equal(201);
|
|
expect(res.body).to.include({
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
portions: 2,
|
|
portionsRequested: 2,
|
|
discardedPortions: 0,
|
|
});
|
|
expect(res.body.sourceItem).to.deep.equal({ id: source.id });
|
|
// The recipe is copied from the source, not sent by the client.
|
|
expect(res.body.recipe.id).to.equal(recipe.id);
|
|
});
|
|
|
|
it("rejects assigning more portions than the source pool still has, with 409 LEFTOVER_QUANTITY_EXCEEDED", async () => {
|
|
const { agent, source } = await setupWithSource(10); // pool of 2
|
|
|
|
// Take 2 of the 2 available...
|
|
await agent.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
sourceItemId: source.id,
|
|
portions: 2,
|
|
});
|
|
// ...then ask for 1 more — nothing left.
|
|
const res = await agent.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mercredi",
|
|
meal: "diner",
|
|
sourceItemId: source.id,
|
|
portions: 1,
|
|
});
|
|
|
|
expect(res.status).to.equal(409);
|
|
expect(res.body.code).to.equal(ErrorCode.LEFTOVER_QUANTITY_EXCEEDED);
|
|
});
|
|
|
|
it("404s when the source item belongs to another household", async () => {
|
|
const { source } = await setupWithSource(10);
|
|
|
|
const stranger = request.agent(app);
|
|
await stranger.post("/auth/signup").send(buildSignupPayload());
|
|
await stranger.post("/house").send({ name: "Ailleurs" });
|
|
|
|
const res = await stranger.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
sourceItemId: source.id,
|
|
portions: 1,
|
|
});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
|
|
});
|
|
|
|
it("404s when the source item is itself a leftovers item (no leftovers-on-leftovers)", async () => {
|
|
const { agent, source } = await setupWithSource(10);
|
|
const leftover = await agent.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
sourceItemId: source.id,
|
|
portions: 1,
|
|
});
|
|
|
|
const res = await agent.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mercredi",
|
|
meal: "diner",
|
|
sourceItemId: leftover.body.id,
|
|
portions: 1,
|
|
});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
|
|
});
|
|
|
|
it("deleting the source item cascades to every leftovers item served from it", async () => {
|
|
const { agent, source } = await setupWithSource(10);
|
|
const leftover = await agent.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
sourceItemId: source.id,
|
|
portions: 2,
|
|
});
|
|
|
|
await agent.delete(`/planning/items/${source.id}`).expect(204);
|
|
|
|
const stillThere = await prisma.planningItem.findUnique({ where: { id: leftover.body.id } });
|
|
expect(stillThere).to.equal(null);
|
|
});
|
|
|
|
it("discards the whole remaining pool, and is idempotent", async () => {
|
|
const { agent, source } = await setupWithSource(10); // pool of 2
|
|
|
|
const first = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
|
|
expect(first.status).to.equal(200);
|
|
expect(first.body.discardedPortions).to.equal(2);
|
|
|
|
// Nothing left — a second call changes nothing.
|
|
const second = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
|
|
expect(second.status).to.equal(200);
|
|
expect(second.body.discardedPortions).to.equal(2);
|
|
|
|
// And the pool is now empty for an assignment too.
|
|
const assign = await agent.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
sourceItemId: source.id,
|
|
portions: 1,
|
|
});
|
|
expect(assign.status).to.equal(409);
|
|
});
|
|
|
|
it("only discards what's still available after some was already served", async () => {
|
|
const { agent, source } = await setupWithSource(10); // pool of 2
|
|
await agent.post("/planning/items/leftover").send({
|
|
date: today(),
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
sourceItemId: source.id,
|
|
portions: 1,
|
|
});
|
|
|
|
const res = await agent.patch(`/planning/items/${source.id}/discard-leftover`);
|
|
expect(res.status).to.equal(200);
|
|
// 1 of the 2 was served; only the other 1 gets discarded.
|
|
expect(res.body.discardedPortions).to.equal(1);
|
|
});
|
|
|
|
it("404s discarding an unknown planning item", async () => {
|
|
const { agent } = await setupWithSource(4);
|
|
const res = await agent.patch("/planning/items/999999/discard-leftover");
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.PLANNING_ITEM_NOT_FOUND);
|
|
});
|
|
});
|
|
});
|