batchCooking/apps/api/test/shopping-list.test.ts
kyuno053 109dde9c7b
feat(shopping-list): liste de courses agrégée depuis le planning (#73)
GET /shopping-list?date= (shopping-list.service.ts/.routes.ts) somme les
ingrédients de chaque recette planifiée sur la semaine, mis à l'échelle par
les portions de chaque créneau (PlanningItem.portions / Recipe.portions),
regroupés par paire (ingredientId, unitId) — jamais null contrairement à
GET /planning, une semaine vide redescend en items: [].

Côté web, ShoppingListPage rend cette liste groupée par rayon (même
IngredientCategory que IngredientPicker), triée alphabétiquement en
français à l'intérieur d'un rayon (shopping-list.ts, logique pure extraite
du composant). WeekNavigator (flèches + calendrier) est extrait de
PlanningPage vers features/planning/ pour être partagé entre les deux
pages ; ses libellés migrent de planning.* vers common.weekNav.*/
common.calendar.*/common.days.*, plus génériques pour une page qui n'est
plus seulement le planning.

ComingSoonPage retiré (plus aucun appelant, Liste de courses avait le
dernier stub restant).

Tests : Mocha (agrégation, mise à l'échelle par portions, unités non
fusionnées) + Cucumber (shopping-list.feature : liste vide, groupement/tri,
navigation de semaine) + mise à jour de layout.cy.ts/planning-page.cy.ts
pour le nouveau rendu.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:45:06 +02:00

336 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 /shopping-list`'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;
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helper as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid — same helper as `recipe.test.ts`. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
describe("Shopping list", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /shopping-list", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/shopping-list").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("/shopping-list");
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("/shopping-list").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("/shopping-list").query({ date: "2026-02-30" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty list when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.deep.equal([]);
});
it("returns an empty list when the household has no planning covering that date", 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("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.deep.equal([]);
});
it("sums one recipe's ingredient across two planning slots, scaled by each slot's own portions", 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 tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
// Written for 2 portions, 100g tomato — planned twice this week at
// 4 portions each, so the shopping list should show 100 × (4/2) × 2
// = 400g, not the raw 200g the recipe itself lists.
const recipe = await prisma.recipe.create({
data: {
name: "Salade de tomates",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
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: recipe.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mercredi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0].ingredient.key).to.equal("tomato");
expect(res.body.items[0].unit.key).to.equal("gram");
expect(res.body.items[0].quantity).to.equal(400);
});
it("sums the same ingredient across two different recipes sharing a unit", 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 gramId = await unitId("gram");
const recipeA = await prisma.recipe.create({
data: {
name: "Soupe à l'oignon",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: onionId, quantity: 200, unitId: gramId }] },
},
});
const recipeB = await prisma.recipe.create({
data: {
name: "Tarte à l'oignon",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: onionId, quantity: 150, unitId: gramId }] },
},
});
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: recipeA.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: recipeB.id,
portions: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0].ingredient.key).to.equal("onion");
expect(res.body.items[0].quantity).to.equal(350);
});
it("keeps the same ingredient in two different units as two separate lines", 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 tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const kilogramId = await unitId("kilogram");
const recipeA = await prisma.recipe.create({
data: {
name: "Recette A",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const recipeB = await prisma.recipe.create({
data: {
name: "Recette B",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 1, unitId: kilogramId }] },
},
});
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: recipeA.id,
portions: 2,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: recipeB.id,
portions: 2,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(2);
const units = res.body.items.map((item: { unit: { key: string } }) => item.unit.key).sort();
expect(units).to.deep.equal(["gram", "kilogram"]);
});
it("returns a different week's shopping list 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 authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const recipe = await prisma.recipe.create({
data: {
name: "Curry de lentilles",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
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,
},
});
const nextWeekRes = await agent.get("/shopping-list").query({ date: isoDate(nextWeek) });
expect(nextWeekRes.body.items).to.have.length(1);
const thisWeekRes = await agent.get("/shopping-list").query({ date: today() });
expect(thisWeekRes.body.items).to.deep.equal([]);
});
});
});