API: endpoints foyer/profil (nom, régime, allergènes) (step 2/6)
- 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.
This commit is contained in:
parent
9b7c955019
commit
1d03effc77
22 changed files with 605 additions and 17 deletions
22
README.md
22
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`) :
|
||||
|
|
|
|||
16
apps/api/features/household.feature
Normal file
16
apps/api/features/household.feature
Normal file
|
|
@ -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"
|
||||
28
apps/api/features/profile.feature
Normal file
28
apps/api/features/profile.feature
Normal file
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
12
apps/api/features/step-definitions/household.steps.ts
Normal file
12
apps/api/features/step-definitions/household.steps.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
46
apps/api/features/step-definitions/profile.steps.ts
Normal file
46
apps/api/features/step-definitions/profile.steps.ts
Normal file
|
|
@ -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<number[]> {
|
||||
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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
14
apps/api/src/lib/safe-profile.ts
Normal file
14
apps/api/src/lib/safe-profile.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<UserProfile, "passwordHash">;
|
||||
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<AuthResult> {
|
|||
|
||||
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}` },
|
||||
|
|
|
|||
28
apps/api/src/modules/house/house.routes.ts
Normal file
28
apps/api/src/modules/house/house.routes.ts
Normal file
|
|
@ -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<unknown, AuthLocals>(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<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = renameHouseSchema.parse(req.body);
|
||||
const house = await renameHouse(res.locals.userProfile.houseId, input.name);
|
||||
res.status(200).json(house);
|
||||
}),
|
||||
);
|
||||
40
apps/api/src/modules/house/house.service.ts
Normal file
40
apps/api/src/modules/house/house.service.ts
Normal file
|
|
@ -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<HouseView | null> {
|
||||
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<HouseView> {
|
||||
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<HouseView> {
|
||||
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;
|
||||
}
|
||||
39
apps/api/src/modules/profile/profile.routes.ts
Normal file
39
apps/api/src/modules/profile/profile.routes.ts
Normal file
|
|
@ -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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = updateAllergiesSchema.parse(req.body);
|
||||
const allergyIds = await updateAllergies(res.locals.userProfile.id, input.allergyIds);
|
||||
res.status(200).json(allergyIds);
|
||||
}),
|
||||
);
|
||||
76
apps/api/src/modules/profile/profile.service.ts
Normal file
76
apps/api/src/modules/profile/profile.service.ts
Normal file
|
|
@ -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<SafeUserProfile> {
|
||||
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<number[]> {
|
||||
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<number[]> {
|
||||
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;
|
||||
}
|
||||
81
apps/api/test/house.test.ts
Normal file
81
apps/api/test/house.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
apps/api/test/profile.test.ts
Normal file
128
apps/api/test/profile.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
12
packages/shared/src/schemas/household.ts
Normal file
12
packages/shared/src/schemas/household.ts
Normal file
|
|
@ -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<typeof renameHouseSchema>;
|
||||
17
packages/shared/src/schemas/profile.ts
Normal file
17
packages/shared/src/schemas/profile.ts
Normal file
|
|
@ -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<typeof updateDietSchema>;
|
||||
|
||||
/** 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<typeof updateAllergiesSchema>;
|
||||
9
packages/shared/src/types/household.ts
Normal file
9
packages/shared/src/types/household.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue