batchCooking/specs/error-handling.md
kyuno053 de63be7ba4
Centralize error handling + code quality pass (comments, SCSS theming) (#7)
* Centralize error handling (shared codes + API/client services), code quality pass

## Error handling

Requested: a centralized error-handling service on the API, custom error
codes shared across apps, and a client-side error service for i18n labels.

- packages/shared/src/errors/error-codes.ts — ErrorCode enum + ApiErrorResponse
  contract. Single source of truth: neither side hardcodes a raw error string
  the other has to guess at.
- apps/api: HttpError now carries an ErrorCode (not just a message).
  ErrorHandlerService (new) centralizes every "how do we turn a thrown error
  into an HTTP response" decision — app.ts's error middleware is now a thin
  adapter calling into it. API messages reverted to English/dev-facing (they
  were French from an earlier pass) since user-facing text is now generated
  client-side from the code.
- apps/web: ApiClient (class, singleton instance) throws ApiError carrying
  the code. ErrorMessageService (new) maps every ErrorCode to a localized
  label, structured with a Locale type from the start (only "fr" exists, but
  adding a language later is "add a locale to the map", not "hunt down every
  hardcoded string"). LoginPage/SignupPage now display
  errorMessageService.getLabel(err.code), never err.message directly.
- Tests strengthened to assert on `code`, not just HTTP status (Mocha +
  Cucumber, new "the response error code should be" step). Cypress mocks
  updated to the new {code, message} response shape.

## Code quality pass

Per explicit feedback: heavy JSDoc on every interface/type/class/function/
method/member touched in this PR, explicit public/private visibility on
every class member (ApiClient, ErrorMessageService, ErrorHandlerService,
HttpError), no HTML/logic mixing (styling extracted out of components
entirely, never inline).

ApiClient/ErrorMessageService were initially written as static-only classes;
switched to instance-based singletons (matching ErrorHandlerService's
existing pattern) after Biome's noStaticOnlyClass rule flagged the
static-only shape as an anti-pattern — same "class with visibility
modifiers" outcome, without fighting the linter.

## SCSS + theming

- apps/web/src/styles/_theme.scss — design tokens as CSS custom properties
  on :root (colors, spacing, typography), not plain Sass variables — makes
  them available at runtime, not just compile time, so a future theme
  switch (e.g. dark mode) is "redefine these variables" rather than
  rebuilding stylesheets.
- apps/web/src/styles/global.scss replaces the old single index.css:
  reset + theme import only, loaded once from main.tsx.
- Per-page/component styles colocated (HomePage.tsx + HomePage.scss);
  styles shared by multiple pages within one feature live in that feature's
  folder (features/auth/auth-form.scss, used by both Login/SignupPage) —
  not duplicated per page, not dumped in the global stylesheet either.
- Component-level .scss files intentionally don't `@use` the theme
  partial: they only consume CSS custom properties (global at runtime via
  global.scss), not Sass-level symbols, so importing it would do nothing —
  documented inline rather than left as a silently-redundant import.
- vite.config.ts opts into Sass's modern compiler API to silence a
  legacy-js-api deprecation warning on every build.

## specs/ updates

- New specs/error-handling.md — the ErrorCode/ApiErrorResponse contract,
  both services, with a flow diagram.
- New specs/frontend-architecture.md — apps/web folder structure, routing/
  auth-guard flow, SCSS/theming conventions.
- specs/batch-cooking-architecture.md links to both (original doc content
  otherwise untouched — it's the user's own hand-authored source doc).

## Verification

Full lint/mocha/cucumber/build green. Manually re-verified the whole auth
flow in a real browser against native dev servers (not just the automated
suites): signup, the EMAIL_ALREADY_IN_USE → "Cet email est déjà utilisé"
translation end-to-end (confirmed the raw API response carries the English
dev message + code, and the UI shows the French label), wrong-password
INVALID_CREDENTIALS → its label, and confirmed the theme tokens actually
apply (computed button background-color matches --color-primary, card
max-width matches the token value) rather than trusting the build succeeding.

* Address review: no .d.ts, express-tools package, faker fixtures, numeric codes, real i18n lib

Five explicit review points, addressed on this same PR branch (not a new
PR) per updated preference.

## No .d.ts files in the codebase

- apps/web: vite-env.d.ts removed — its /// <reference types="vite/client" />
  is replaced by "types": ["vite/client"] in tsconfig.app.json, same effect.
- apps/api: src/types/express.d.ts renamed to express-request.augment.ts —
  `declare global` module augmentation works identically in a plain .ts
  file as long as it has a top-level import (making it a module); the
  .d.ts extension wasn't doing anything for us here.

## packages/express-tools — separate package for Express tooling

Moved HttpError and ErrorHandlerService out of apps/api into a new
workspace package, plus a new createErrorMiddleware() factory (the actual
Express 4-arg error-handling middleware, previously inlined in app.ts).
apps/api now just consumes @batch-cooking/express-tools. Has a real build
(tsc -> dist/, same pattern as packages/shared) — required for the same
reason shared needed one: apps/api's Docker image runs plain `node
dist/server.js`, no tsx. apps/api/Dockerfile updated to COPY the new
package's dist alongside shared's.

## faker.js for test fixtures

apps/api/test/auth.test.ts: replaced the hardcoded "Nicolas
Lefevre"/nicolas@example.com fixture (looked like real user data) with
@faker-js/faker, generated fresh per test via buildSignupPayload().
features/step-definitions/auth.steps.ts: fakerized the filler
firstName/lastName/password used for background state the scenarios
don't actually read.

Deliberately did NOT fakerize the literal example values inside
auth.feature itself (alice@example.com etc.) — those are the readable,
illustrative Gherkin examples that are the whole point of BDD scenarios,
not real PII, and randomizing them would make the scenarios harder to
read for no real gain. Flagged this reasoning in the README in case that
call should go the other way.

Caught a real bug while wiring this up: faker.internet.email() sometimes
capitalizes parts of the address, but signupSchema/loginSchema normalize
emails to lowercase — the test fixture needs to match what's actually
stored, so buildSignupPayload() lowercases the generated email too.
Found by actually running the suite repeatedly, not just once.

## ErrorCode: numeric enum, zero hardcoded values

packages/shared/src/errors/error-codes.ts: ErrorCode is now a numeric
enum (4000 VALIDATION_ERROR, 4001 EMAIL_ALREADY_IN_USE, 4010
INVALID_CREDENTIALS, 4011 NOT_AUTHENTICATED, 4040 NOT_FOUND, 5000
INTERNAL_ERROR — grouped by family like HTTP status codes).

Audited and fixed every place that hardcoded a raw code value instead of
referencing the enum: ApiClient's fallback (`"INTERNAL_ERROR" as
ErrorCode` — would no longer even type-check once the enum went numeric,
which is exactly the point), and the Cypress mock bodies (now import
ErrorCode from @batch-cooking/shared instead of typing the string).

Cucumber's "the response error code should be {string}" step still takes
the *name* in the .feature file (readable: "EMAIL_ALREADY_IN_USE") and
resolves it to the real numeric value via ErrorCode[name] — TypeScript's
reverse enum mapping — before comparing, so the Gherkin stays readable
without the step hardcoding a number either.

## Real i18n library (i18next), not a hand-rolled label map

apps/web: added i18next + react-i18next. New locales/fr/translation.json
holds every user-facing string — not just error labels (errors.*), but
the login/signup/home pages' labels, buttons and headings too
(auth.login.*, auth.signup.*, home.*) — via useTranslation()/t() in each
page. ErrorMessageService no longer owns its own label map; it converts
the numeric ErrorCode to its enum member name and delegates the actual
lookup to i18next (errors.<MEMBER_NAME>). Adding a language is now
"add a locale file", not a code change anywhere.

## specs/ and README updated

specs/error-handling.md and specs/frontend-architecture.md rewritten for
the new package, numeric codes, and i18next. New "i18n" and "no .d.ts"
sections. README covers the same, plus a note on the faker.js scope
decision (feature-file literals excluded, on purpose).

## Verification

Full lint/mocha (x3 runs)/cucumber/build green. Re-verified
express-tools' extraction against a real risk (not just tsc passing):
ran `node dist/server.js` standalone (mirrors the Docker runtime, no
tsx) and hit /health, a 404 (confirmed numeric code 4040 over the wire),
and a real signup + duplicate-email 409 (confirmed numeric 4001). Then
re-verified the full pipeline in a real browser against native dev
servers: signup, EMAIL_ALREADY_IN_USE -> i18next -> "Cet email est déjà
utilisé" end-to-end, home page i18next interpolation
({{firstName}}/{{lastName}}) rendering correctly.

* Address second review round: interface comments, res.locals, ExpressServer, assertIsNever

Five more explicit review points, on the same PR branch.

## Every interface key commented

Audited all 6 interfaces in the codebase. Two had partially-commented
members (violates the "every key gets /** */" rule): AuthResult
(apps/api/auth.service.ts) and SafeUserProfile (packages/shared) — both
now fully commented. The other four (AuthTokenPayload,
AuthContextValue, ErrorHandlingResult, ApiErrorResponse) were already
compliant.

## Removed the Express namespace augmentation

apps/api/src/types/express.d.ts (renamed to express-request.augment.ts
in the last round) is gone entirely. requireAuth now attaches the
authenticated profile to `res.locals.userProfile` — Express's own
built-in per-request mechanism for exactly this — typed via a new
AuthLocals interface and `Response<unknown, AuthLocals>`, instead of a
project-wide `declare global` silently changing every Request's type
whether or not it went through the middleware.

## ErrorHandlerService confirmed framework-agnostic

It already had zero Express import. Documented this explicitly (in the
package's index.ts and the new backend-architecture.md spec) as a
deliberate split: ErrorHandlerService is framework-agnostic (would work
behind Fastify too), ExpressServer/createErrorMiddleware are the actual
Express integration layer.

## packages/express-tools: server init + route/middleware utilities

New ExpressServer class, modeled on the pattern shared as a reference
(adapted, not copied 1:1 — deliberately left out the reference's custom
runtime param-type-validation system, since zod already does that job
in this codebase and running two parallel validation mechanisms would
be redundant, not "propre"):
- setupCore() — the common cors/json/cookie-parser stack
- addRoute() — registers a route, warns+skips instead of silently
  double-registering the same method+path
- addMiddleware() / mountRouter() / setErrorHandler()
- listen()
- .instance — the raw Express app, for supertest

Also added wrapAsyncHandler() — forwards a thrown/rejected error from an
async handler to next(err) automatically, removing the manual
try/catch/next(err) every route needed.

apps/api/src/app.ts now builds via ExpressServer (createServer(),
consumed by both server.ts's .listen() and createApp()'s .instance for
tests). auth.routes.ts's signup/login handlers use wrapAsyncHandler
instead of manual try/catch. cookie-parser/cors moved out of apps/api's
own dependencies entirely — they're express-tools' concern now.

## assertIsNever (packages/shared/src/tools/)

Exhaustiveness-check helper for switch/if-chains over a union: takes a
`never`-typed value and throws, so a forgotten case in a later-added
union member becomes a compile error instead of a silent runtime
fallthrough. Verified for real (not just written and assumed correct):
wrote a throwaway switch missing a case and confirmed `tsc` rejects it
with the exact expected error, then deleted the scratch file. No
existing switch/if-chain over a union in the codebase yet to retrofit
it into — noted as ready for when one appears (e.g. the not-yet-built
batch-cooking calculation module or recipe-import pipeline).

## specs/ updated

New specs/backend-architecture.md — ExpressServer, wrapAsyncHandler,
the res.locals decision (with the "why not declare global" reasoning
spelled out), assertIsNever. error-handling.md and
frontend-architecture.md cross-link to it instead of duplicating.
README covers the same, briefly.

## Verification

Full lint/mocha/cucumber/build green. Re-ran `node dist/server.js`
standalone (mirrors Docker, no tsx) after the ExpressServer refactor:
/health, a 404 (numeric 4040), and a real signup + GET /me round trip
confirming res.locals-based auth actually works at runtime, not just
that tsc accepts the types.

* refactor: move ErrorHandlerService/HttpError out of express-tools

ErrorHandlerService has zero dependency on Express — it's a plain
"map an error to {status, body}" service that works identically
behind any HTTP framework. It had no business living in a package
named express-tools.

Extracted HttpError, ErrorHandlerService, and ErrorHandlingResult
into a new packages/error-tools package (same tsc-build-to-dist
pattern as shared/express-tools). express-tools now only keeps the
actual Express-specific layer: ExpressServer, wrapAsyncHandler, and
createErrorMiddleware (which adapts ErrorHandlerService, imported
from error-tools, onto Express).

- packages/error-tools: new package, depends on shared + zod
- packages/express-tools: drops zod dependency, adds error-tools
  dependency for error-middleware.ts's type import
- apps/api: adds error-tools dependency; app.ts, auth.service.ts,
  require-auth.ts now import HttpError/errorHandlerService from
  error-tools instead of express-tools
- apps/api/Dockerfile: adds COPY for packages/error-tools in the
  runtime stage
- specs/error-handling.md, specs/backend-architecture.md, README.md
  updated to reflect the new package split

Verified: pnpm lint, pnpm build (all packages, correct dependency
order), pnpm test (9/9 Mocha), pnpm test:bdd (5/5 Cucumber), full
Docker rebuild + compose up (no crash-loop), curl + browser checks
of /health, unknown-route 404, signup (201), duplicate-email 409
(code 4001 EMAIL_ALREADY_IN_USE) — all going through the moved
ErrorHandlerService/HttpError correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 18:40:53 +02:00

176 lines
8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Gestion des erreurs — Projet Batch-cooking
> Documentation du contrat d'erreurs partagé entre `apps/api` et `apps/web`.
---
## Vue d'ensemble
Quatre pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
l'affichage utilisateur, passe par un chemin unique et prévisible :
- **`packages/shared`** — le contrat : `ErrorCode` (énumération **numérique** de
tous les codes d'erreur métier) et `ApiErrorResponse` (forme JSON de toute
réponse d'erreur de l'API). Ni l'API ni le web ne définissent leur propre liste
de codes, et aucune valeur n'est jamais codée en dur ailleurs (toujours
`ErrorCode.XXX`, jamais un nombre/une chaîne littérale).
- **`packages/error-tools`** — package séparé, **indépendant de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Le mapping
« erreur → `{ status, body }` » n'a rien de spécifique à Express, donc il ne vit
pas dans `express-tools`.
- **`packages/express-tools`** — package séparé pour l'outillage Express générique
(réutilisable par n'importe quel service Express du monorepo, pas seulement
`apps/api`) : `createErrorMiddleware` (adapte `ErrorHandlerService` à l'API
Express), `ExpressServer`, `wrapAsyncHandler`.
- **`apps/api`** — consomme les deux : lève des `HttpError` (`error-tools`), le
middleware d'erreur final n'est qu'un appel à
`createErrorMiddleware(errorHandlerService)` (`express-tools`).
- **`apps/web` → `ErrorMessageService`** — associe chaque `ErrorCode` à une clé de
traduction, résolue via **i18next** (fichiers de locale sous `src/locales/`).
Les composants n'écrivent jamais de texte d'erreur en dur.
```mermaid
flowchart LR
subgraph ERRTOOLS["packages/error-tools"]
HTTPERR["HttpError"]
EHS["ErrorHandlerService.handle()"]
end
subgraph TOOLS["packages/express-tools"]
MW["createErrorMiddleware()"]
end
subgraph API["apps/api"]
THROW["Route / service<br/>throw new HttpError(status, code, message)"]
THROW --> EHS
MW -->|"app.use(...)"| EHS
end
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
subgraph WEB["apps/web"]
CLIENT["ApiClient<br/>lève ApiError(status, code, ...)"]
EMS["ErrorMessageService.getLabel(code)"]
I18N["i18next<br/>locales/fr/translation.json"]
UI["Composant (LoginPage, SignupPage...)"]
CLIENT --> EMS --> I18N --> UI
end
HTTP --> CLIENT
SHARED[("packages/shared<br/>ErrorCode (numérique), ApiErrorResponse")]
SHARED -. contrat .-> THROW
SHARED -. contrat .-> CLIENT
SHARED -. contrat .-> EMS
style SHARED fill:none,stroke:#888,stroke-width:1px
style ERRTOOLS fill:none,stroke:#888,stroke-width:1px
style TOOLS fill:none,stroke:#888,stroke-width:1px
```
---
## Le contrat (`packages/shared/src/errors/error-codes.ts`)
```ts
enum ErrorCode {
VALIDATION_ERROR = 4000,
EMAIL_ALREADY_IN_USE = 4001,
INVALID_CREDENTIALS = 4010,
NOT_AUTHENTICATED = 4011,
NOT_FOUND = 4040,
INTERNAL_ERROR = 5000,
}
interface ApiErrorResponse {
code: ErrorCode;
message: string; // anglais, dev-facing — jamais affiché tel quel côté UI
details?: Record<string, string[] | undefined>; // uniquement pour VALIDATION_ERROR
}
```
**Codes numériques, groupés par famille** (comme les codes HTTP) : `4000``4099`
validation, `4010``4019` authentification, `4040``4049` ressource introuvable,
`5000``5099` interne. Le numéro donne une indication de la catégorie même sans
regarder l'enum.
**Règle** : `message` est destiné aux logs/au débogage (toujours en anglais, jamais
localisé). Le texte affiché à l'utilisateur vient **toujours** de
`ErrorMessageService.getLabel(code)` côté client, jamais de `message` directement.
Et **aucune valeur `ErrorCode` n'est jamais écrite en dur** (ni en nombre, ni en
chaîne) — toujours une référence `ErrorCode.XXX`, y compris dans les tests/mocks.
Pour ajouter un nouveau cas d'erreur :
1. Ajouter le membre dans `ErrorCode`, dans la bonne plage numérique.
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
3. Ajouter sa traduction dans **chaque** fichier `apps/web/src/locales/*/translation.json`, sous `errors.XXX`.
---
## `packages/error-tools` — les pièces liées aux erreurs, indépendantes du framework
- **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et
`code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
construire une réponse HTTP à la main.
- **`error-handler.service.ts`** — `ErrorHandlerService` : un seul point qui sait
transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi
d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue
l'erreur côté serveur sans jamais exposer de détail interne au client.
**N'importe pas `express`** — c'est un service générique, indépendant du
framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre. C'est
précisément pour ça qu'il vit dans son propre package plutôt que dans
`express-tools` : rien ici ne dépend d'Express, donc rien ici n'a sa place dans
un package *express*-tools.
Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
pas en TS brut — voir la note dans
[frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
sur pourquoi ça compte pour un runtime Node pur (Docker).
## `packages/express-tools` — l'adaptateur Express
`packages/express-tools` contient `ExpressServer` (init serveur, enregistrement
de routes/middlewares) et `wrapAsyncHandler` — voir
[backend-architecture.md](./backend-architecture.md) pour le détail complet du
package. La pièce qui concerne spécifiquement les erreurs :
- **`error-middleware.ts`** — `createErrorMiddleware(service: ErrorHandlerService)` :
construit le middleware d'erreur Express (signature à 4 arguments) à partir
d'un `ErrorHandlerService` importé de `@batch-cooking/error-tools` — c'est LUI
la vraie couche Express, `ErrorHandlerService` reste agnostique. `express-tools`
dépend de `error-tools`, jamais l'inverse.
## Côté API (`apps/api`)
- **`app.ts`** — le middleware d'erreur final est enregistré via
`server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir
[backend-architecture.md](./backend-architecture.md) pour `ExpressServer`) ;
aucune logique de mapping n'y vit directement, tout est dans `error-tools`.
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
importent `HttpError` depuis `@batch-cooking/error-tools` et `ErrorCode` depuis
`@batch-cooking/shared`.
## Côté Web (`apps/web`)
- **`api/client.ts`** — `ApiClient` : lève `ApiError` (porteur de `status`, `code`,
`fieldErrors`) pour toute réponse non-2xx.
- **`services/error-message.service.ts`** — `ErrorMessageService` : convertit le
`ErrorCode` numérique reçu en nom de membre (`ErrorCode[code]`, ex. `4001`
`"EMAIL_ALREADY_IN_USE"`), puis délègue la traduction à **i18next**
(`i18n.t(\`errors.${memberName}\`)`). N'a pas sa propre table de libellés — c'est
i18next + les fichiers de locale qui la portent.
- **`i18n/i18n.ts`** + **`locales/fr/translation.json`** — configuration et
ressources i18next. Ajouter une langue = ajouter une entrée `resources.<lng>`
pointant vers un nouveau fichier de locale, sans toucher un seul composant.
- Les pages (`LoginPage`, `SignupPage`) attrapent `ApiError`, récupèrent `err.code`,
et appellent `errorMessageService.getLabel(err.code)` pour l'afficher — jamais
`err.message`.
## Validation côté formulaire (distincte du contrat d'erreurs API)
Les schémas zod partagés (`packages/shared/src/schemas/auth.ts`) portent leurs
propres messages en français, utilisés pour la validation **avant** l'appel réseau
(retour instantané, aucun aller-retour serveur). C'est un mécanisme séparé du
contrat `ErrorCode`/i18next : ces messages ne quittent jamais le navigateur, et ne
vivent pas dans les fichiers de locale (ils sont dans `packages/shared`, consommé
aussi par l'API qui ne dépend pas d'i18next).