batchCooking/specs/error-handling.md
Nicolas 1cedb25d74 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.
2026-08-16 16:22:29 +02:00

6.8 KiB
Raw Blame History

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/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) : HttpError, ErrorHandlerService, createErrorMiddleware.
  • apps/api — consomme express-tools : lève des HttpError, le middleware d'erreur final n'est qu'un appel à createErrorMiddleware(errorHandlerService).
  • apps/webErrorMessageService — 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.
flowchart LR
  subgraph TOOLS["packages/express-tools"]
    HTTPERR["HttpError"]
    EHS["ErrorHandlerService.handle()"]
    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 TOOLS fill:none,stroke:#888,stroke-width:1px

Le contrat (packages/shared/src/errors/error-codes.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) : 40004099 validation, 40104019 authentification, 40404049 ressource introuvable, 50005099 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/express-tools — outillage Express générique

Séparé d'apps/api volontairement : ce sont des briques génériques (n'importe quel service Express du monorepo pourrait les utiliser), pas de logique métier.

  • http-error.tsHttpError : 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.tsErrorHandlerService : 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.
  • error-middleware.tscreateErrorMiddleware(service) : construit le middleware d'erreur Express (signature à 4 arguments) à partir du service — adaptateur fin, aucune logique de mapping n'y vit.

Build réel (tscdist/, comme packages/shared) : consommé en JS compilé, pas en TS brut — voir la note dans frontend-architecture.md sur pourquoi ça compte pour un runtime Node pur (Docker).

Côté API (apps/api)

  • app.ts — le middleware d'erreur final est app.use(createErrorMiddleware(errorHandlerService)) ; aucune logique de mapping n'y vit directement, tout est dans express-tools.
  • Les modules métier (modules/auth/auth.service.ts, middlewares/require-auth.ts) importent HttpError depuis @batch-cooking/express-tools et ErrorCode depuis @batch-cooking/shared.

Côté Web (apps/web)

  • api/client.tsApiClient : lève ApiError (porteur de status, code, fieldErrors) pour toute réponse non-2xx.
  • services/error-message.service.tsErrorMessageService : 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).