diff --git a/apps/api/features/step-definitions/planning.steps.ts b/apps/api/features/step-definitions/planning.steps.ts index 12c36eb..952660a 100644 --- a/apps/api/features/step-definitions/planning.steps.ts +++ b/apps/api/features/step-definitions/planning.steps.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; +import { DateTime } from "@batch-cooking/date-tools"; import { Given, Then, When } from "@cucumber/cucumber"; import { prisma } from "../../src/db/prisma.js"; import type { CustomWorld } from "../support/world.js"; +/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to "today". */ When("I request the current planning", async function (this: CustomWorld) { - this.response = await this.agent.get("/planning/current"); + this.response = await this.agent.get("/planning").query({ date: DateTime.utc().toISODate() }); }); Then("the current planning response should be empty", function (this: CustomWorld) { diff --git a/apps/api/package.json b/apps/api/package.json index ddb49cb..e8bdb61 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@batch-cooking/date-tools": "workspace:*", "@batch-cooking/error-tools": "workspace:*", "@batch-cooking/express-tools": "workspace:*", "@batch-cooking/shared": "workspace:*", diff --git a/apps/api/src/modules/planning/planning.routes.ts b/apps/api/src/modules/planning/planning.routes.ts index 278cf2e..39eb8e2 100644 --- a/apps/api/src/modules/planning/planning.routes.ts +++ b/apps/api/src/modules/planning/planning.routes.ts @@ -1,21 +1,36 @@ +import { parseDateOnly } from "@batch-cooking/date-tools"; +import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { getCurrentPlanning } from "./planning.service.js"; +import { getPlanningForDate } from "./planning.service.js"; /** Router mounted at `/planning` in app.ts. */ export const planningRouter = Router(); /** - * Returns the authenticated user's household's planning for today, or - * `null` if none exists yet — a valid, common response, not an error (see - * {@link getCurrentPlanning}). + * Returns the authenticated user's household's planning covering `?date=` + * (`YYYY-MM-DD`), or `null` if none exists yet — a valid, common response, + * not an error (see {@link getPlanningForDate}). Used both for "today" + * (the planning page's initial load) and for any other week the planning + * page's week navigator/calendar picks. */ planningRouter.get( - "/current", + "/", requireAuth, - wrapAsyncHandler(async (_req, res) => { - const planning = await getCurrentPlanning(res.locals.userProfile.houseId); + wrapAsyncHandler(async (req, res) => { + const input = getPlanningByDateSchema.parse(req.query); + const date = parseDateOnly(input.date); + if (date === null) { + throw new HttpError( + 400, + ErrorCode.VALIDATION_ERROR, + `Not a real calendar date: ${input.date}`, + ); + } + + const planning = await getPlanningForDate(res.locals.userProfile.houseId, date); res.status(200).json(planning); }), ); diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts index 67eda25..380645e 100644 --- a/apps/api/src/modules/planning/planning.service.ts +++ b/apps/api/src/modules/planning/planning.service.ts @@ -1,35 +1,40 @@ +import { type DateTime, toDateOnly } from "@batch-cooking/date-tools"; import type { PlanningView } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; /** - * Finds the household's planning that covers today's date and shapes it - * into a {@link PlanningView} (recipes resolved to `{id, name}`). + * Finds the household's planning that covers `date` and shapes it into a + * {@link PlanningView} (recipes resolved to `{id, name}`). `date` is + * whatever the caller wants "now" to mean — the current `/planning` route + * passes a `date-tools`-parsed `?date=` query param, letting a caller look + * up any week's planning, not just the one covering today. * - * Returns `null` for two distinct, both entirely normal states — a `house_id` - * of `null` (a profile always gets a house at signup today, but the column - * is nullable) and "no planning row covers today" (the expected case until - * planning creation is built) — neither is an error, so both collapse to - * the same "nothing to show yet" result rather than throwing. + * Returns `null` for two distinct, both entirely normal states — a + * `houseId` of `null` (the profile has no household yet — households are no + * longer created automatically at signup, see `auth.service.ts`) and "no + * planning row covers this date" (the expected case until planning + * creation is built) — neither is an error, so both collapse to the same + * "nothing to show yet" result rather than throwing. */ -export async function getCurrentPlanning(houseId: number | null): Promise { +export async function getPlanningForDate( + houseId: number | null, + date: DateTime, +): Promise { if (houseId === null) { return null; } // `startDate`/`finishDate` are `@db.Date` columns (no time-of-day - // component) — compare against today's date at UTC midnight so the - // comparison lines up with how Postgres stores/returns them, regardless - // of the server's local timezone. - const today = new Date(); - const todayDateOnly = new Date( - Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()), - ); + // component) — comparing against a UTC-midnight JS `Date` lines up with + // how Postgres stores/returns them, regardless of the server's local + // timezone. + const dateOnly = toDateOnly(date).toJSDate(); const planning = await prisma.planning.findFirst({ where: { houseId, - startDate: { lte: todayDateOnly }, - finishDate: { gte: todayDateOnly }, + startDate: { lte: dateOnly }, + finishDate: { gte: dateOnly }, }, // A household should never have two plannings covering the same day, // but nothing in the schema enforces that yet — pick the most recently diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index 71fffe0..2dd885d 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -1,3 +1,4 @@ +import { DateTime } from "@batch-cooking/date-tools"; import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; import { faker } from "@faker-js/faker"; import { expect } from "chai"; @@ -18,6 +19,18 @@ function buildSignupPayload(): SignupInput { }; } +/** Today, as the `YYYY-MM-DD` string `GET /planning`'s `?date=` expects. */ +function today(): string { + return isoDate(DateTime.utc()); +} + +/** `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(); @@ -29,63 +42,93 @@ describe("Planning", () => { await prisma.$disconnect(); }); - describe("GET /planning/current", () => { + describe("GET /planning", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { - const res = await request(app).get("/planning/current"); + 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("returns null when the household has no planning covering today", async () => { + 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/current"); + 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 today, with recipes resolved", async () => { + 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" } }); - const today = new Date(); + const now = new Date(); const planning = await prisma.planning.create({ data: { houseId, startDate: new Date( - Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2), + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 2), ), finishDate: new Date( - Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2), + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2), ), }, }); await prisma.planningItem.create({ - data: { planningId: planning.id, weekDay: "monday", meal: "dinner", recipeId: recipe.id }, + data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id }, }); - const res = await agent.get("/planning/current"); + 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: "monday", meal: "dinner" }); + expect(res.body.items[0]).to.include({ weekDay: "lundi", meal: "diner" }); expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" }); }); - it("returns null when the household's planning does not cover today", async () => { + 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 as "current". + // A planning entirely in the past — shouldn't be picked up for today. await prisma.planning.create({ data: { houseId, @@ -94,10 +137,38 @@ describe("Planning", () => { }, }); - const res = await agent.get("/planning/current"); + 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" } }); + const nextWeek = DateTime.utc().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 }, + }); + + 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); + }); }); });