Module cooking-session : charge le Planning couvrant ?date= (meme requete "plage couvrante" + degradation "jamais null" que /shopping-list), mappe chaque PlanningItem vers l'entree pure de optimizeCookingPlan (ingredients/ unites/techniques/ustensiles resolus via toIngredientView/toUnitView reutilisees de recipe.service), renvoie OptimizedCookingPlanView. - cookingSessionPlanningInclude reprend le sous-arbre steps de recipeInclude. - Route requireAuth, contrat ?date= identique a /shopping-list. - Monte /cooking-session dans app.ts. - Tests d'integration Mocha (401, date invalide, plan vide sans foyer / sans planning, mutualisation d'une decoupe entre 2 recettes planifiees). - specs/batch-cooking-architecture.md : module "Calcul batch-cooking" TODO -> v1 implementee ; nouvelle section dans backend-architecture.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
204 lines
7.1 KiB
TypeScript
204 lines
7.1 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");
|
|
|
|
/** A recipe: one pure-prep "chop onion" step, then one simmer step. */
|
|
async function makeRecipe(name: string, onionQty: number) {
|
|
return prisma.recipe.create({
|
|
data: {
|
|
name,
|
|
authorId,
|
|
portions: 4,
|
|
steps: {
|
|
create: [
|
|
{
|
|
order: 0,
|
|
description: "Émincer les oignons",
|
|
techSteps: {
|
|
create: [
|
|
{
|
|
techStepId: chopId,
|
|
order: 0,
|
|
ingredients: {
|
|
create: [
|
|
{
|
|
ingredientId: onionId,
|
|
quantity: onionQty,
|
|
unitId: pieceId,
|
|
start: 0,
|
|
end: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
order: 1,
|
|
description: "Faire mijoter",
|
|
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const soupe = await makeRecipe("Soupe", 2);
|
|
const tarte = await makeRecipe("Tarte", 3);
|
|
|
|
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,
|
|
},
|
|
{
|
|
planningId: planning.id,
|
|
weekDay: "mardi",
|
|
meal: "diner",
|
|
recipeId: tarte.id,
|
|
portions: 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);
|
|
});
|
|
});
|
|
});
|