API: GET /planning?date= remplace GET /planning/current (step 2/4)
- getPlanningForDate(houseId, date: DateTime) — paramétré au lieu de toujours "aujourd'hui", même logique de recherche sinon - GET /planning?date=YYYY-MM-DD, validation de forme (zod) puis de validité calendaire (parseDateOnly, 400 VALIDATION_ERROR sinon) — un seul endpoint générique au lieu de deux qui se recouvrent - Tests Mocha + Cucumber adaptés, + cas date manquante/malformée/ impossible et "semaine différente d'aujourd'hui"
This commit is contained in:
parent
b930453878
commit
9b2b2c2e28
5 changed files with 133 additions and 39 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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<unknown, AuthLocals>(async (_req, res) => {
|
||||
const planning = await getCurrentPlanning(res.locals.userProfile.houseId);
|
||||
wrapAsyncHandler<unknown, AuthLocals>(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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<PlanningView | null> {
|
||||
export async function getPlanningForDate(
|
||||
houseId: number | null,
|
||||
date: DateTime,
|
||||
): Promise<PlanningView | null> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue