From 1d03effc77a57d5a1507f90833244bf03daab71c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 16 Aug 2026 23:18:46 +0200 Subject: [PATCH] =?UTF-8?q?API:=20endpoints=20foyer/profil=20(nom,=20r?= =?UTF-8?q?=C3=A9gime,=20allerg=C3=A8nes)=20(step=202/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET/PATCH /house/current — renomme le foyer de l'utilisateur connecté. PATCH avec houseId null -> 404 HOUSE_NOT_FOUND. - PATCH /profile/diet { dietId: number | null } — régime du profil ; null l'efface (étape skippable du parcours). dietId invalide -> 404 DIET_NOT_FOUND. - GET/PATCH /profile/allergies — allergènes/intolérances, liste d'IDs ; PATCH remplace l'ensemble complet (pas une fusion, cohérent avec un multi-select). ID invalide -> 404 ALLERGY_NOT_FOUND. - 3 nouveaux ErrorCode (4041-4043) + libellés fr. - Extraction de toSafeProfile() dans src/lib/safe-profile.ts — auparavant dupliqué dans auth.service.ts et require-auth.ts, profile.service.ts le réutilise aussi. - Tests Mocha (28 passing) + Cucumber (15 scenarios) — même convention que le reste, doc README. Deuxième commit de la feature profil/foyer/régime/allergènes — composants front partagés dans le commit suivant. --- README.md | 22 +++ apps/api/features/household.feature | 16 +++ apps/api/features/profile.feature | 28 ++++ .../features/step-definitions/health.steps.ts | 7 + .../step-definitions/household.steps.ts | 12 ++ .../step-definitions/profile.steps.ts | 46 +++++++ apps/api/src/app.ts | 4 + apps/api/src/lib/safe-profile.ts | 14 ++ apps/api/src/middlewares/require-auth.ts | 4 +- apps/api/src/modules/auth/auth.service.ts | 27 ++-- apps/api/src/modules/house/house.routes.ts | 28 ++++ apps/api/src/modules/house/house.service.ts | 40 ++++++ .../api/src/modules/profile/profile.routes.ts | 39 ++++++ .../src/modules/profile/profile.service.ts | 76 +++++++++++ apps/api/test/house.test.ts | 81 +++++++++++ apps/api/test/profile.test.ts | 128 ++++++++++++++++++ apps/web/src/locales/fr/translation.json | 3 + packages/shared/src/errors/error-codes.ts | 6 + packages/shared/src/index.ts | 3 + packages/shared/src/schemas/household.ts | 12 ++ packages/shared/src/schemas/profile.ts | 17 +++ packages/shared/src/types/household.ts | 9 ++ 22 files changed, 605 insertions(+), 17 deletions(-) create mode 100644 apps/api/features/household.feature create mode 100644 apps/api/features/profile.feature create mode 100644 apps/api/features/step-definitions/household.steps.ts create mode 100644 apps/api/features/step-definitions/profile.steps.ts create mode 100644 apps/api/src/lib/safe-profile.ts create mode 100644 apps/api/src/modules/house/house.routes.ts create mode 100644 apps/api/src/modules/house/house.service.ts create mode 100644 apps/api/src/modules/profile/profile.routes.ts create mode 100644 apps/api/src/modules/profile/profile.service.ts create mode 100644 apps/api/test/house.test.ts create mode 100644 apps/api/test/profile.test.ts create mode 100644 packages/shared/src/schemas/household.ts create mode 100644 packages/shared/src/schemas/profile.ts create mode 100644 packages/shared/src/types/household.ts diff --git a/README.md b/README.md index afdac88..bb2f2a8 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,28 @@ spec d'origine) précisément pour permettre cet upsert idempotent par nom. Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste standard, pas inventée. +## Foyer & profil — nom, régime, allergènes (apps/api) + +Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de +référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer. + +- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie + `null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée + toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil + n'a pas de foyer). +- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ; + `null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si + `dietId` ne correspond à aucun régime de référence. +- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté, + sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace** + l'ensemble (pas une fusion — le client renvoie toujours la sélection complète, + cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID + ne correspond à aucun allergène de référence. + +`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash` +(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et +`require-auth.ts` — `profile.service.ts` le réutilise aussi. + ## Page de connexion / inscription (apps/web) - `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) : diff --git a/apps/api/features/household.feature b/apps/api/features/household.feature new file mode 100644 index 0000000..94a56e2 --- /dev/null +++ b/apps/api/features/household.feature @@ -0,0 +1,16 @@ +Feature: Household name + As a signed-in user + I want to name my household + So that it's recognizable as ours, not the auto-generated default + + Scenario: A visitor without a session cannot read the household + When I send a GET request to "/house/current" + Then the response status should be 401 + And the response error code should be "NOT_AUTHENTICATED" + + Scenario: A signed-in user renames their household + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I rename my household to "Chez les Martin" + Then the response status should be 200 + And my household should be named "Chez les Martin" diff --git a/apps/api/features/profile.feature b/apps/api/features/profile.feature new file mode 100644 index 0000000..deca688 --- /dev/null +++ b/apps/api/features/profile.feature @@ -0,0 +1,28 @@ +Feature: Profile regime and allergens + As a signed-in user + I want to set my dietary regime and allergens/intolerances + So that the household's meal planning can account for them later + + Scenario: A visitor without a session cannot set a regime + When I send a PATCH request to "/profile/diet" with body: + """ + { "dietId": 1 } + """ + Then the response status should be 401 + And the response error code should be "NOT_AUTHENTICATED" + + Scenario: A signed-in user sets their regime to a valid, seeded diet + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I set my regime to "Végétarien" + Then the response status should be 200 + And my profile's regime should be "Végétarien" + + Scenario: A signed-in user selects allergens, then replaces the selection + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I set my allergens to "Arachides, Gluten" + Then the response status should be 200 + And my selected allergens should be "Arachides, Gluten" + When I set my allergens to "Lait" + Then my selected allergens should be "Lait" diff --git a/apps/api/features/step-definitions/health.steps.ts b/apps/api/features/step-definitions/health.steps.ts index c69fc92..052fe87 100644 --- a/apps/api/features/step-definitions/health.steps.ts +++ b/apps/api/features/step-definitions/health.steps.ts @@ -8,6 +8,13 @@ When("I send a GET request to {string}", async function (this: CustomWorld, path this.response = await request(this.app).get(path); }); +When( + "I send a PATCH request to {string} with body:", + async function (this: CustomWorld, path: string, body: string) { + this.response = await request(this.app).patch(path).send(JSON.parse(body)); + }, +); + Then("the response status should be {int}", function (this: CustomWorld, status: number) { assert.equal(this.response.status, status); }); diff --git a/apps/api/features/step-definitions/household.steps.ts b/apps/api/features/step-definitions/household.steps.ts new file mode 100644 index 0000000..4a8dabb --- /dev/null +++ b/apps/api/features/step-definitions/household.steps.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { Then, When } from "@cucumber/cucumber"; +import type { CustomWorld } from "../support/world.js"; + +When("I rename my household to {string}", async function (this: CustomWorld, name: string) { + this.response = await this.agent.patch("/house/current").send({ name }); +}); + +Then("my household should be named {string}", async function (this: CustomWorld, name: string) { + const res = await this.agent.get("/house/current"); + assert.equal(res.body.name, name); +}); diff --git a/apps/api/features/step-definitions/profile.steps.ts b/apps/api/features/step-definitions/profile.steps.ts new file mode 100644 index 0000000..f9e432f --- /dev/null +++ b/apps/api/features/step-definitions/profile.steps.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { Then, When } from "@cucumber/cucumber"; +import { prisma } from "../../src/db/prisma.js"; +import type { CustomWorld } from "../support/world.js"; + +/** Splits a comma-separated list of names from a `.feature` string into trimmed, non-empty parts. */ +function splitNames(names: string): string[] { + return names + .split(",") + .map((name) => name.trim()) + .filter(Boolean); +} + +/** Resolves allergen names (Category.name) to their Allergy id — see reference.service.ts for why the name lives on Category, not Allergy. */ +async function allergyIdsFor(names: string[]): Promise { + const allergies = await prisma.allergy.findMany({ include: { category: true } }); + return names.map((name) => { + const match = allergies.find((allergy) => allergy.category.name === name); + if (!match) throw new Error(`No seeded allergen named "${name}"`); + return match.id; + }); +} + +When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) { + const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); + this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id }); +}); + +Then( + "my profile's regime should be {string}", + async function (this: CustomWorld, dietName: string) { + const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); + assert.equal(this.response.body.dietId, diet.id); + }, +); + +When("I set my allergens to {string}", async function (this: CustomWorld, names: string) { + const allergyIds = await allergyIdsFor(splitNames(names)); + this.response = await this.agent.patch("/profile/allergies").send({ allergyIds }); +}); + +Then("my selected allergens should be {string}", async function (this: CustomWorld, names: string) { + const expected = (await allergyIdsFor(splitNames(names))).sort(); + const actual = [...this.response.body].sort(); + assert.deepEqual(actual, expected); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6b6e2f7..c4f8727 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,7 +4,9 @@ import { ErrorCode } from "@batch-cooking/shared"; import type { Express, Request, Response } from "express"; import { env } from "./config/env.js"; import { authRouter } from "./modules/auth/auth.routes.js"; +import { houseRouter } from "./modules/house/house.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js"; +import { profileRouter } from "./modules/profile/profile.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js"; /** @@ -24,7 +26,9 @@ export function createServer(): ExpressServer { }); server.mountRouter("/auth", authRouter); + server.mountRouter("/house", houseRouter); server.mountRouter("/planning", planningRouter); + server.mountRouter("/profile", profileRouter); server.mountRouter("/reference", referenceRouter); // No route matched — same shape as every other error response, via the diff --git a/apps/api/src/lib/safe-profile.ts b/apps/api/src/lib/safe-profile.ts new file mode 100644 index 0000000..ecf40e7 --- /dev/null +++ b/apps/api/src/lib/safe-profile.ts @@ -0,0 +1,14 @@ +import type { SafeUserProfile } from "@batch-cooking/shared"; +import type { UserProfile } from "@prisma/client"; + +/** + * Strips `passwordHash` off a Prisma `UserProfile` before it's ever sent to + * a client. Shared by every module that hands a profile back to the + * caller (`auth.service.ts`, `require-auth.ts`, `profile.service.ts`) — + * previously duplicated inline in each, consolidated here so there's one + * place this security-relevant stripping happens. + */ +export function toSafeProfile(profile: UserProfile): SafeUserProfile { + const { passwordHash: _passwordHash, ...safeProfile } = profile; + return safeProfile; +} diff --git a/apps/api/src/middlewares/require-auth.ts b/apps/api/src/middlewares/require-auth.ts index ba32431..099793c 100644 --- a/apps/api/src/middlewares/require-auth.ts +++ b/apps/api/src/middlewares/require-auth.ts @@ -4,6 +4,7 @@ import type { NextFunction, Request, Response } from "express"; import { env } from "../config/env.js"; import { prisma } from "../db/prisma.js"; import { verifyAuthToken } from "../lib/jwt.js"; +import { toSafeProfile } from "../lib/safe-profile.js"; /** * Shape of `res.locals` once {@link requireAuth} has run successfully. Type @@ -54,8 +55,7 @@ export async function requireAuth( throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); } - const { passwordHash: _passwordHash, ...safeProfile } = profile; - res.locals.userProfile = safeProfile; + res.locals.userProfile = toSafeProfile(profile); next(); } catch (err) { if (err instanceof HttpError) { diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 5fb5a3b..24a6508 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -1,18 +1,20 @@ import { HttpError } from "@batch-cooking/error-tools"; -import { ErrorCode, type LoginInput, type SignupInput } from "@batch-cooking/shared"; -import type { UserProfile } from "@prisma/client"; +import { + ErrorCode, + type LoginInput, + type SafeUserProfile, + type SignupInput, +} from "@batch-cooking/shared"; import argon2 from "argon2"; import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; import { signAuthToken } from "../../lib/jwt.js"; - -/** A UserProfile as it's safe to hand back to a client — never the password hash. */ -type SafeProfile = Omit; +import { toSafeProfile } from "../../lib/safe-profile.js"; /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */ interface AuthResult { /** The authenticated profile, safe to hand back to the client. */ - profile: SafeProfile; + profile: SafeUserProfile; /** Signed session JWT — the caller sets this as the session cookie's value. */ token: string; } @@ -25,12 +27,6 @@ interface AuthResult { const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 }; const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; -/** Strips `passwordHash` off a Prisma UserProfile before it's ever sent to a client. */ -function toSafeProfile(profile: UserProfile): SafeProfile { - const { passwordHash: _passwordHash, ...safeProfile } = profile; - return safeProfile; -} - /** * Creates a new household (`house`) and profile (`user_profiles`) together * in one transaction, hashes the password, and issues a session token. @@ -45,9 +41,10 @@ export async function signup(input: SignupInput): Promise { const passwordHash = await argon2.hash(input.password, hashOptions); - // A profile always belongs to a house; signup creates one (named after - // the new user for now — renaming/joining an existing house is a - // separate, not-yet-built feature). + // A profile always belongs to a house; signup creates one, named after + // the new user for now — renamed via `PATCH /house/current` (the + // household step of the profile journey). Joining an existing house is a + // separate, not-yet-built feature. const profile = await prisma.$transaction(async (tx) => { const house = await tx.house.create({ data: { name: `Foyer de ${input.firstName}` }, diff --git a/apps/api/src/modules/house/house.routes.ts b/apps/api/src/modules/house/house.routes.ts new file mode 100644 index 0000000..ec9c67d --- /dev/null +++ b/apps/api/src/modules/house/house.routes.ts @@ -0,0 +1,28 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { renameHouseSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { getCurrentHouse, renameHouse } from "./house.service.js"; + +/** Router mounted at `/house` in app.ts. Both routes require a session — a household is per-user (via their profile), never public. */ +export const houseRouter = Router(); + +houseRouter.get( + "/current", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + const house = await getCurrentHouse(res.locals.userProfile.houseId); + res.status(200).json(house); + }), +); + +/** The household step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ +houseRouter.patch( + "/current", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = renameHouseSchema.parse(req.body); + const house = await renameHouse(res.locals.userProfile.houseId, input.name); + res.status(200).json(house); + }), +); diff --git a/apps/api/src/modules/house/house.service.ts b/apps/api/src/modules/house/house.service.ts new file mode 100644 index 0000000..6a34438 --- /dev/null +++ b/apps/api/src/modules/house/house.service.ts @@ -0,0 +1,40 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { ErrorCode, type HouseView } from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; + +/** Returns the profile's household, or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ +export async function getCurrentHouse(houseId: number | null): Promise { + if (houseId === null) { + return null; + } + return findHouseOrThrow(houseId); +} + +/** + * Renames the profile's household. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. + */ +export async function renameHouse(houseId: number | null, name: string): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + await findHouseOrThrow(houseId); + return prisma.house.update({ where: { id: houseId }, data: { name } }); +} + +/** + * A profile's `houseId` is only ever set to a real house (foreign key, + * never assigned by hand) — a lookup miss here means the referenced row + * was deleted out from under a still-linked profile, an internal + * inconsistency rather than a normal "not found" a client could hit + * through the API, hence a plain `Error` (500) rather than a + * `HOUSE_NOT_FOUND` HttpError. + */ +async function findHouseOrThrow(houseId: number): Promise { + const house = await prisma.house.findUnique({ where: { id: houseId } }); + if (!house) { + throw new Error(`House ${houseId} referenced by a profile but not found`); + } + return house; +} diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts new file mode 100644 index 0000000..7732fba --- /dev/null +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -0,0 +1,39 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js"; + +/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */ +export const profileRouter = Router(); + +/** The regime step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ +profileRouter.patch( + "/diet", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = updateDietSchema.parse(req.body); + const profile = await updateDiet(res.locals.userProfile.id, input.dietId); + res.status(200).json(profile); + }), +); + +profileRouter.get( + "/allergies", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + const allergyIds = await getAllergyIds(res.locals.userProfile.id); + res.status(200).json(allergyIds); + }), +); + +/** The allergen/intolerance step of the profile journey — same callers as PATCH /diet above. */ +profileRouter.patch( + "/allergies", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = updateAllergiesSchema.parse(req.body); + const allergyIds = await updateAllergies(res.locals.userProfile.id, input.allergyIds); + res.status(200).json(allergyIds); + }), +); diff --git a/apps/api/src/modules/profile/profile.service.ts b/apps/api/src/modules/profile/profile.service.ts new file mode 100644 index 0000000..487a19c --- /dev/null +++ b/apps/api/src/modules/profile/profile.service.ts @@ -0,0 +1,76 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; +import { toSafeProfile } from "../../lib/safe-profile.js"; + +/** + * Sets (or clears, if `dietId` is `null`) a profile's dietary regime — the + * regime step of the profile journey is skippable, so `null` is a normal, + * valid value, not an omission to reject. + * + * @throws {HttpError} `404 DIET_NOT_FOUND` if `dietId` doesn't match a reference `Diet` row. + */ +export async function updateDiet( + userProfileId: number, + dietId: number | null, +): Promise { + if (dietId !== null) { + const diet = await prisma.diet.findUnique({ where: { id: dietId } }); + if (!diet) { + throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`); + } + } + + const profile = await prisma.userProfile.update({ + where: { id: userProfileId }, + data: { dietId }, + }); + return toSafeProfile(profile); +} + +/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */ +export async function getAllergyIds(userProfileId: number): Promise { + const rows = await prisma.userProfileAllergy.findMany({ + where: { userProfileId }, + select: { allergyId: true }, + }); + return rows.map((row) => row.allergyId); +} + +/** + * Replaces a profile's full allergen set (not a merge — the caller sends + * the complete list every time, same shape the multi-select UI already + * holds). Validates every id up front so a partially-invalid request never + * leaves the set half-updated. + * + * @throws {HttpError} `404 ALLERGY_NOT_FOUND` if any `allergyId` doesn't match a reference `Allergy` row. + */ +export async function updateAllergies( + userProfileId: number, + allergyIds: number[], +): Promise { + if (allergyIds.length > 0) { + const found = await prisma.allergy.findMany({ + where: { id: { in: allergyIds } }, + select: { id: true }, + }); + const foundIds = new Set(found.map((allergy) => allergy.id)); + const missing = allergyIds.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + throw new HttpError( + 404, + ErrorCode.ALLERGY_NOT_FOUND, + `Unknown allergy id(s): ${missing.join(", ")}`, + ); + } + } + + await prisma.$transaction([ + prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }), + prisma.userProfileAllergy.createMany({ + data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })), + }), + ]); + + return allergyIds; +} diff --git a/apps/api/test/house.test.ts b/apps/api/test/house.test.ts new file mode 100644 index 0000000..87cd0cf --- /dev/null +++ b/apps/api/test/house.test.ts @@ -0,0 +1,81 @@ +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 { resetDatabase } from "../test-support/reset-db.js"; + +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 }), + }; +} + +describe("Household", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /house/current", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/house/current"); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("returns the household created at signup", async () => { + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/house/current"); + + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ id: signupRes.body.houseId, name: res.body.name }); + }); + }); + + describe("PATCH /house/current", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).patch("/house/current").send({ name: "Chez nous" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("renames the household", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/house/current").send({ name: "Chez les Dupont" }); + + expect(res.status).to.equal(200); + expect(res.body.name).to.equal("Chez les Dupont"); + + const refetch = await agent.get("/house/current"); + expect(refetch.body.name).to.equal("Chez les Dupont"); + }); + + it("rejects an empty name with 400 VALIDATION_ERROR", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/house/current").send({ name: "" }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + }); +}); diff --git a/apps/api/test/profile.test.ts b/apps/api/test/profile.test.ts new file mode 100644 index 0000000..f515935 --- /dev/null +++ b/apps/api/test/profile.test.ts @@ -0,0 +1,128 @@ +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 { resetDatabase } from "../test-support/reset-db.js"; + +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 }), + }; +} + +describe("Profile", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("PATCH /profile/diet", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).patch("/profile/diet").send({ dietId: 1 }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("sets the profile's regime to a valid, seeded diet", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } }); + + const res = await agent.patch("/profile/diet").send({ dietId: diet.id }); + + expect(res.status).to.equal(200); + expect(res.body.dietId).to.equal(diet.id); + }); + + it("clears the regime when dietId is null", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végan" } }); + await agent.patch("/profile/diet").send({ dietId: diet.id }); + + const res = await agent.patch("/profile/diet").send({ dietId: null }); + + expect(res.status).to.equal(200); + expect(res.body.dietId).to.equal(null); + }); + + it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/profile/diet").send({ dietId: 999_999 }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND); + }); + }); + + describe("GET /profile/allergies + PATCH /profile/allergies", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const getRes = await request(app).get("/profile/allergies"); + const patchRes = await request(app).patch("/profile/allergies").send({ allergyIds: [] }); + + expect(getRes.status).to.equal(401); + expect(patchRes.status).to.equal(401); + }); + + it("starts empty, then reflects a saved selection", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const allergies = await prisma.allergy.findMany({ include: { category: true } }); + const peanuts = allergies.find((a) => a.category.name === "Arachides"); + const gluten = allergies.find((a) => a.category.name === "Gluten"); + if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); + + const initial = await agent.get("/profile/allergies"); + expect(initial.body).to.deep.equal([]); + + const patchRes = await agent + .patch("/profile/allergies") + .send({ allergyIds: [peanuts.id, gluten.id] }); + expect(patchRes.status).to.equal(200); + expect(patchRes.body.sort()).to.deep.equal([peanuts.id, gluten.id].sort()); + + const refetch = await agent.get("/profile/allergies"); + expect(refetch.body.sort()).to.deep.equal([peanuts.id, gluten.id].sort()); + }); + + it("replaces (not merges) the previous selection", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const allergies = await prisma.allergy.findMany({ include: { category: true } }); + const peanuts = allergies.find((a) => a.category.name === "Arachides"); + const gluten = allergies.find((a) => a.category.name === "Gluten"); + if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); + + await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] }); + await agent.patch("/profile/allergies").send({ allergyIds: [gluten.id] }); + + const res = await agent.get("/profile/allergies"); + expect(res.body).to.deep.equal([gluten.id]); + }); + + it("rejects an unknown allergyId with 404 ALLERGY_NOT_FOUND", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/profile/allergies").send({ allergyIds: [999_999] }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND); + }); + }); +}); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 5917c23..397c238 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -5,6 +5,9 @@ "INVALID_CREDENTIALS": "Email ou mot de passe incorrect", "NOT_AUTHENTICATED": "Vous devez être connecté", "NOT_FOUND": "Ressource introuvable", + "HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer", + "DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas", + "ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" }, "auth": { diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index 52a99a3..b245726 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -34,6 +34,12 @@ export enum ErrorCode { NOT_AUTHENTICATED = 4011, /** No route/resource matches the request. */ NOT_FOUND = 4040, + /** The profile making the request has no household yet (`houseId` is `null`). */ + HOUSE_NOT_FOUND = 4041, + /** A `dietId` was given that doesn't match any reference `Diet` row. */ + DIET_NOT_FOUND = 4042, + /** One or more `allergyIds` don't match any reference `Allergy` row. */ + ALLERGY_NOT_FOUND = 4043, /** Unexpected/unhandled failure — the catch-all, always logged server-side. */ INTERNAL_ERROR = 5000, } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c600342..8f20709 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,7 +5,10 @@ export * from "./errors/error-codes.js"; export * from "./schemas/auth.js"; +export * from "./schemas/household.js"; +export * from "./schemas/profile.js"; export * from "./tools/assert-is-never.js"; +export * from "./types/household.js"; export * from "./types/planning.js"; export * from "./types/reference.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/schemas/household.ts b/packages/shared/src/schemas/household.ts new file mode 100644 index 0000000..bd138aa --- /dev/null +++ b/packages/shared/src/schemas/household.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +// Shared between apps/api (server-side validation) and apps/web (client-side +// validation for instant feedback) — see schemas/auth.ts for the full +// rationale, same pattern here. + +/** Payload accepted by `PATCH /house/current`. */ +export const renameHouseSchema = z.object({ + name: z.string().trim().min(1, "Le nom du foyer est requis").max(100), +}); +/** Inferred TS type for {@link renameHouseSchema}'s validated output. */ +export type RenameHouseInput = z.infer; diff --git a/packages/shared/src/schemas/profile.ts b/packages/shared/src/schemas/profile.ts new file mode 100644 index 0000000..92e0a81 --- /dev/null +++ b/packages/shared/src/schemas/profile.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +// See schemas/auth.ts for the shared client/server validation rationale. + +/** Payload accepted by `PATCH /profile/diet`. `null` clears the profile's regime — this step of the profile journey is skippable. */ +export const updateDietSchema = z.object({ + dietId: z.number().int().positive().nullable(), +}); +/** Inferred TS type for {@link updateDietSchema}'s validated output. */ +export type UpdateDietInput = z.infer; + +/** Payload accepted by `PATCH /profile/allergies`. Replaces the profile's full allergy set — an empty array clears it (also skippable). */ +export const updateAllergiesSchema = z.object({ + allergyIds: z.array(z.number().int().positive()), +}); +/** Inferred TS type for {@link updateAllergiesSchema}'s validated output. */ +export type UpdateAllergiesInput = z.infer; diff --git a/packages/shared/src/types/household.ts b/packages/shared/src/types/household.ts new file mode 100644 index 0000000..32b7c93 --- /dev/null +++ b/packages/shared/src/types/household.ts @@ -0,0 +1,9 @@ +/** + * A household, as returned by `GET /house/current` / `PATCH /house/current`. + * Unlike `DietView`/`AllergyView` this isn't reference data — it's the + * current user's own household. + */ +export interface HouseView { + id: number; + name: string; +}