No description
Find a file
kyuno053 f1fefc1f38
refactor: sépare les tests Cypress en parcours utilisateur / layout / composants (#25)
* chore: point de départ pour le refactor des tests Cypress

Sépare les tests Cypress en trois catégories, comme discuté :

1. Parcours utilisateur (cypress/e2e/) — scénarios Gherkin/Cucumber,
   pilotés par @badeball/cypress-cucumber-preprocessor@22.2.0 (validé
   sur experiment/cucumber-cypress, mergée). Ex. "En tant
   qu'utilisateur, je peux créer un compte".

2. Layout applicatif (nouveau dossier à définir) — specs Cypress
   classiques (pas de Gherkin), cy.visit() sur une vraie page, mais
   centrées sur la disposition/visibilité des éléments, indépendamment
   d'un parcours utilisateur scripté.

3. Composants génériques (cypress/component/, nouveau) — vrai
   Component Testing Cypress, composant React monté isolément (pas de
   routeur, pas de backend). Composants concernés aujourd'hui :
   components/ui/{Checkbox,Radio,Dialog}.tsx.

Premier pas : mise en place de l'infra Component Testing (config +
devServer Vite + adapter React 18), validée sur un composant simple
avant de construire le reste.

* feat(web): infra Cypress Component Testing, premier test sur CheckboxOption

Étape 1 du refactor (voir PR) : met en place le vrai mode Component
Testing de Cypress, séparé de l'e2e — monte un composant React isolé
(pas de routeur, pas de backend), pour tester les composants
génériques (components/ui/) indépendamment de tout parcours
utilisateur.

- cypress.config.ts : nouveau bloc `component` (devServer Vite, réutilise
  vite.config.ts de l'appli — même plugin React, même Sass). Le hook
  GPU-disable est factorisé (`disableGpu`) puisque e2e et component ont
  chacun leur propre `setupNodeEvents`, pas de config partagée par défaut.
- cypress/support/component.ts + component-index.html : fichiers de
  support standards Cypress CT — importe le vrai global.scss de l'appli
  (les composants génériques sont stylés via lui, pas de CSS scopé à eux).
- cypress/component/CheckboxOption.cy.tsx : 4 scénarios sur
  components/ui/Checkbox.tsx (rendu du label, reflet du prop `checked`
  sur l'input natif + la classe `is-selected`, callback `onChange` avec
  la valeur inversée, comportement contrôlé via un wrapper avec état).

Dépendance ajoutée, épinglée : @cypress/vite-dev-server@5.2.1 (dernière
version sans peer dependency cypress >=14 — 6.0.3+ l'exige explicitement,
on est sur cypress@13.17.0).

Testé en local jusqu'au mur GPU/Electron habituel (config + devServer
Vite chargent sans erreur) — l'exécution réelle du montage reste à
vérifier via la CI.

* ci: exécute les tests de composants Cypress

Sans ça, `cypress/component/CheckboxOption.cy.tsx` (commit précédent)
ne tournait jamais en CI : `pnpm --filter web e2e` lance `cypress run`
sans `--component`, donc uniquement la suite e2e par défaut.

- apps/web/package.json : nouveau script `cy:run:component`
- ci.yml : étape dédiée après `pnpm --filter web e2e`, sans
  start-server-and-test (Cypress lance son propre dev server Vite en
  interne pour le component testing, pas besoin d'attendre l'appli
  comme pour l'e2e)

* test(web): refactor user journeys into Cucumber scenarios

Convertit les parcours utilisateur (goal-driven, "en tant que X je peux
Y") en scénarios Gherkin, en réutilisant l'infra Cucumber déjà validée
par le smoke test (PR #24). Retire le smoke test jetable maintenant
superflu.

8 fichiers .feature ajoutés, chacun avec son fichier de step definitions
au même basename (convention de découverte du préprocesseur — voir
login-smoke.ts) :

- auth.feature : inscription (succès, erreur validation, email déjà
  pris), connexion (succès, identifiants invalides), déconnexion
- onboarding.feature : les 3 scénarios déjà couverts (wizard complet,
  étapes sautées, rejoindre un foyer pendant l'onboarding) — dépend de
  household-settings.ts et preferences.ts pour ses steps de
  création/rejoint de foyer et de sélection de régime/allergies
- household-settings.feature : créer un foyer, rejoindre par code
  d'invitation, renommer (autosave), retirer un membre, supprimer le
  foyer, quitter le foyer
- account.feature : suppression de compte (mauvais mot de passe,
  succès, annulation)
- recipe-form.feature : les 4 scénarios déjà couverts inchangés (ajout
  d'ingrédient + création, régression crypto.randomUUID, exclusion/
  réinclusion d'ingrédient, préchargement + édition d'une recette
  existante)
- recipes.feature : bascule favori, suppression d'une recette
- preferences.feature : autosave du régime, autosave des allergies
- user-preferences.feature : changement de thème (autosave)

En contrepartie, les anciens .cy.ts perdent uniquement les it() migrés
vers Gherkin — les scénarios de layout/affichage pur (catalogue de
recettes, tabs, recherche, panneau de détail, sidebar, planning grid,
etc.) restent en Cypress classique, conformément au découpage
"parcours utilisateur (Cucumber) vs layout (Cypress pur)" déjà en
place pour les component tests. auth.cy.ts, onboarding.cy.ts et
recipe-form.cy.ts sont supprimés : 100% de leur contenu a migré.

Les commentaires "voir auth.cy.ts pour la justification" désormais
obsolètes (fichier supprimé) sont remplacés par une explication
autonome du mock cy.intercept.

Vérifié statiquement : les 246 steps Gherkin des 8 .feature résolvent
chacun vers exactement une définition (0 non résolu, 0 ambigu) et
`pnpm exec biome check` est propre sur tout cypress/. Reste à confirmer
en CI que les scénarios passent réellement (pas seulement qu'ils se
résolvent).

* fix(web): fix cross-feature step discovery and slash-alternation bug

La CI de la refonte précédente (commit aaace15) a échoué : la
découverte par défaut du préprocesseur ne charge, pour un fichier
`foo.feature`, QUE `foo.ts` (co-localisé, même basename) et
`cypress/support/step_definitions/**` — pas les autres `.ts` du
dossier `cypress/e2e/`. onboarding.feature référençait donc des steps
qui ne vivaient que dans preferences.ts, household-settings.ts et
auth.ts, introuvables lors de son propre run.

Déplace les steps réellement partagés entre plusieurs .feature vers
cypress/support/step_definitions/ (chargé pour toutes les features) :
- reference-data.steps.ts : mocks des listes de référence régimes/
  allergies (options ou vides) — partagé entre auth.feature et
  onboarding.feature
- household-mutations.steps.ts : création/adhésion à un foyer et leurs
  assertions — partagé entre household-settings.feature et
  onboarding.feature
- profile-mutations.steps.ts : sélection du régime, mise à jour des
  allergies et leur assertion — partagé entre preferences.feature et
  onboarding.feature

Les définitions d'origine sont retirées de auth.ts/household-
settings.ts/onboarding.ts/preferences.ts pour éviter un step
"Ambiguous" (chargé deux fois pour la feature qui les définissait déjà
elle-même).

Corrige aussi un second bug distinct révélé par la même CI :
"the ingredient/diet catalog is available" (recipe-form.feature)
contient un "/" non échappé — en syntaxe Cucumber Expression, "/" hors
d'un paramètre {..} signifie une alternative de texte ("ingredient" OU
"diet catalog is available"), jamais le caractère littéral. Le texte
du .feature ne pouvait donc jamais matcher. Renommé sans "/" :
"the ingredient and diet catalog is available".

Le script de vérification statique utilisé pour valider aaace15 avant
push donnait une fausse confiance : il regroupait tous les steps de
tous les fichiers comme disponibles globalement pour chaque feature,
sans respecter ce scoping réel. Réécrit pour ne charger, par feature,
que son fichier co-localisé + step_definitions/ — et pour détecter les
patterns contenant un "/" non échappé. Résultat : toujours 246 steps,
0 non résolu, 0 ambigu, 0 pattern à slash non échappé, cette fois avec
un modèle de résolution fidèle au comportement réel du préprocesseur.

* test(web): cover every CheckboxOption/RadioOption behavior

Complète les component tests des deux seuls composants UI génériques
committés (Dialog.tsx est un WIP non commité d'une autre fonctionnalité
en cours — hors scope ici) pour couvrir tout leur comportement, pas
seulement le cas heureux.

CheckboxOption — 4 tests existants (rendu, checked/is-selected, onChange
au clic depuis unchecked, contrôlé) complétés par :
- onChange(false) au clic depuis l'état checked (symétrique du test
  existant, qui ne couvrait que checked=false → true)
- fusion du className de l'appelant avec is-selected, dans les deux
  sens (juste className, className+is-selected)
- class="" (chaîne vide, pas "false"/"null") quand aucun className
  n'est passé et que checked=false — pin le comportement exact du
  `.filter(Boolean).join(" ")`
- le clic sur le texte du label (pas seulement l'input) déclenche aussi
  onChange — comportement natif du HTML dont la "carte sélectionnable"
  de global.scss dépend entièrement
- le span .check-mark est aria-hidden

RadioOption — aucun test avant ce commit. Ajouté en couvrant en plus
ce qui distingue vraiment un radio d'un checkbox :
- name/value posés sur l'input natif
- onChange(value) au clic depuis unchecked
- AUCUN onChange au clic sur un radio déjà checked (contrairement à un
  checkbox, un radio natif ne réémet pas `change` si l'état ne change
  pas réellement)
- clic sur le label, className/is-selected, aria-hidden — mêmes
  scénarios que CheckboxOption
- comportement de groupe mutuellement exclusif : 3 RadioOption
  partageant `name="theme"` (mirroring UserPreferencesPage), un seul
  sélectionné à la fois, y compris via `input:checked` natif du
  navigateur

Non exécutable en local (limitation GPU/sandbox Electron documentée
dans le README, pré-existante) — à vérifier en CI.

* test(web): add layout/style regression suite for the app shell

Troisième catégorie du découpage des tests (parcours via Cucumber,
composants génériques via Component Testing, et maintenant layout pur
— indépendant de tout parcours utilisateur). Cypress classique, pas de
Gherkin : ce fichier teste la structure/l'apparence du shell
(AppLayout) lui-même, pas le contenu d'une page donnée.

Couvre spécifiquement les 4 axes demandés :

- Positionnement : la sidebar garde une largeur fixe (240px déplié,
  68px replié) plaquée au coin haut-gauche, sur n'importe quelle page.
- Scroll : régression directe pour #21 — `.app-layout` reste borné
  exactement à la hauteur du viewport (overflow: hidden), et une page
  plus haute que le viewport scrolle uniquement dans `.app-content`
  (via un spacer synthétique de 3000px injecté après le mount, pour
  rester indépendant du contenu réel d'une page donnée) sans jamais
  déplacer la sidebar ni scroller le document lui-même.
- Largeur des pages : autre régression directe pour #21 — le planning
  et le catalogue de recettes remplissent toute la largeur disponible
  de `.app-content`, tandis que la page "Liste de courses" et les
  pages de paramètres restent centrées avec un espace égal de chaque
  côté (le bug original : collées à gauche avec un grand vide à
  droite).
- Breakpoint responsive (< 640px) : la sidebar bascule en barre
  horizontale pleine largeur, masque le bouton collapse/la version,
  et garde chaque lien de nav pleinement lisible (icône + label, avec
  scroll horizontal) plutôt que de les écraser en pastilles de ~16px
  sans texte — un mode de régression explicitement documenté en
  commentaire dans AppLayout.scss mais jusqu'ici non testé.
- Thème de couleur : va au-delà de l'attribut `data-theme` déjà
  couvert par user-preferences.cy.ts — vérifie les vraies valeurs de
  couleur calculées (`getComputedStyle`) sur la sidebar, le lien de
  nav actif et le fond de page, en clair et en sombre, confirmant que
  la cascade CSS des tokens (_theme.scss) atteint réellement le rendu,
  pas seulement que le JS pose le bon attribut.

Non exécutable en local (limitation GPU/sandbox Electron documentée
dans le README, pré-existante) — à vérifier en CI.
2026-08-19 14:31:17 +02:00
.claude Apply "Mise en Place" visual identity to auth/home screens (#8) 2026-08-16 19:43:13 +02:00
.github/workflows refactor: sépare les tests Cypress en parcours utilisateur / layout / composants (#25) 2026-08-19 14:31:17 +02:00
apps refactor: sépare les tests Cypress en parcours utilisateur / layout / composants (#25) 2026-08-19 14:31:17 +02:00
packages chore(web): session de polish global — version, checkbox, danger zone, icônes (#20) 2026-08-18 20:52:12 +02:00
specs Web: allergies/intolérances séparées + hot saving sur /foyer (step 8/8) 2026-08-17 00:09:09 +02:00
.dockerignore Add Docker packaging for local functional review (api + web) (#5) 2026-08-16 14:13:51 +02:00
.env.example fix(api): make the session cookie's Secure flag overridable 2026-08-17 23:49:05 +02:00
.gitignore chore(web): session de polish global — version, checkbox, danger zone, icônes (#20) 2026-08-18 20:52:12 +02:00
.nvmrc Scaffold generic pnpm monorepo (api + web + shared) (#1) 2026-08-16 10:55:13 +02:00
biome.json Scaffold generic pnpm monorepo (api + web + shared) (#1) 2026-08-16 10:55:13 +02:00
docker-compose.yml fix(api): make the session cookie's Secure flag overridable 2026-08-17 23:49:05 +02:00
package.json API: signup/login (profile creation + JWT auth) (#4) 2026-08-16 13:45:23 +02:00
pnpm-lock.yaml refactor: sépare les tests Cypress en parcours utilisateur / layout / composants (#25) 2026-08-19 14:31:17 +02:00
pnpm-workspace.yaml Scaffold generic pnpm monorepo (api + web + shared) (#1) 2026-08-16 10:55:13 +02:00
README.md chore(api): retire l'intégration Cucumber/Gherkin 2026-08-19 12:41:17 +02:00
tsconfig.base.json Scaffold generic pnpm monorepo (api + web + shared) (#1) 2026-08-16 10:55:13 +02:00

batchCooking

Structure

Monorepo pnpm workspaces :

  • apps/api — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha)
  • apps/web — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard. Page de connexion/inscription en place ; le reste est encore un squelette générique.
  • packages/shared — code partagé entre api et web : schémas zod (signupSchema, loginSchema), types (SafeUserProfile), et le contrat d'erreurs (ErrorCode numérique, ApiErrorResponse, voir specs/error-handling.md) — même règles des deux côtés, pas de risque de dérive entre front et back.
  • packages/error-tools — gestion des erreurs, indépendante de tout framework HTTP (n'importe pas express) : HttpError, ErrorHandlerService. Séparé d'express-tools précisément parce que rien ici ne dépend d'Express. Détail : specs/error-handling.md.
  • packages/express-tools — outillage Express générique et réutilisable : ExpressServer (init serveur, routes, middlewares), wrapAsyncHandler, createErrorMiddleware (adapte ErrorHandlerService de error-tools à Express) — séparé d'apps/api, pas de logique métier. Détail : specs/backend-architecture.md.

packages/shared, packages/error-tools et packages/express-tools ont un vrai build (tscdist/, voir leur package.json) : consommés en JS compilé, pas en TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans specs/frontend-architecture.md.

Prérequis

  • Node.js 22 (voir .nvmrc)
  • pnpm 10 (corepack enable puis corepack use pnpm@10.12.4, ou installation manuelle)
  • Docker (pour Postgres en local)

Installation

pnpm install
cp .env.example .env
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env

Puis édite ces deux .env pour renseigner de vrais POSTGRES_USER/POSTGRES_PASSWORD (et la DATABASE_URL correspondante dans apps/api/.env) : les fichiers .env.example ne contiennent volontairement aucun identifiant réel (juste changeme), et docker-compose.yml refuse de démarrer tant que POSTGRES_USER/PASSWORD/DB ne sont pas définis dans .env — pas de valeur par défaut en dur dans les fichiers commités. Même règle pour apps/api/.env : JWT_SECRET est requis, sans défaut (génère le tien, voir le commentaire dans apps/api/.env.example).

Cypress : téléchargement du binaire

pnpm install installe le package cypress mais pas forcément son binaire (le téléchargement du .exe/binaire natif peut être ignoré selon l'environnement où pnpm install a été lancé — ex. un environnement sandboxé/CI dont le cache ne correspond pas à celui de ta machine). Si pnpm --filter web e2e échoue avec une erreur du type :

No version of Cypress is installed in: ...\AppData\Local\Cypress\Cache\...
Please reinstall Cypress by running: cypress install

lance simplement, depuis ta machine :

pnpm --filter web exec cypress install

(à faire une seule fois par machine ; le binaire est mis en cache localement, hors du repo).

Développement

# Base de données Postgres locale
docker compose up -d postgres

# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
pnpm --filter api exec prisma migrate dev

# Backend (http://localhost:3000)
pnpm dev:api

# Frontend (http://localhost:5173)
pnpm dev:web

Conflit de port possible sur 5432 : si tu as déjà un Postgres natif installé sur ta machine (service Windows, Homebrew, etc.), il peut occuper le port 5432 et intercepter les connexions à la place du conteneur Docker (symptôme : Prisma renvoie P1000: Authentication failed alors que les identifiants sont corrects). Dans ce cas, mets POSTGRES_PORT=5433 (ou autre) dans ton .env et adapte le port dans la DATABASE_URL de apps/api/.env.

Toujours cibler postgres, jamais docker compose up -d tout court. Le même docker-compose.yml définit aussi le service app (voir Déploiement) — celui que Portainer construit en production. Sans nom de service, docker compose up -d démarre les deux : ça déclenche un pnpm install sur tout le monorepo (donc aussi le cypress d'apps/web, avec son téléchargement de binaire) rien que pour builder une image dont le dev local n'a pas besoin (on sert le front/back directement via pnpm dev:web/pnpm dev:api, pas ce conteneur).

Qualité / Tests

pnpm lint                  # Biome (lint + format check)
pnpm lint:fix               # Biome --write
pnpm test                   # tests unitaires/intégration (Mocha, apps/api)
pnpm --filter web e2e         # tests e2e (Cypress, démarre le serveur dev automatiquement)
pnpm build                     # build de tous les workspaces

La CI GitHub Actions (.github/workflows/ci.yml) exécute quatre jobs indépendants (lint, test, build, e2e) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers main — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi Déploiement pour le pipeline de release (.github/workflows/release.yml).

Déploiement

Une seule image Docker (apps/api/Dockerfile) sert à la fois l'API et le frontend buildé — plus de conteneur nginx séparé pour apps/web. Le stage build compile apps/api et apps/web (pnpm --filter web build), le stage runtime copie le résultat (apps/web/dist) à côté de l'API ; au démarrage, apps/api/src/app.ts sert ce dossier statique (fallback SPA compris, pour le routing react-router côté client) via FRONTEND_DIST_DIR — voir packages/express-tools/src/express-server.ts (serveStaticFrontend). Cette variable n'est renseignée que dans l'image Docker : en dev natif (pnpm dev:api), elle reste vide et pnpm dev:web continue de servir le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.

docker-compose.yml ne définit donc que deux services : postgres et app (un seul port, APP_PORT, défaut 3000 — plus de WEB_PORT/CORS_ORIGIN à coordonner entre deux origines, le frontend et l'API sont désormais servis depuis la même origine).

Pas de registre d'image dans cette configuration : l'instance Portainer de production est reliée directement au dépôt Git et reconstruit elle-même docker-compose.yml/apps/api/Dockerfile à chaque déploiement — la CI ne pousse donc aucune image nulle part.

Release (.github/workflows/release.yml)

Déclenchée par un tag vX.Y.Z :

git tag vX.Y.Z
git push --tags

Le pipeline enchaîne trois jobs : sanity-build (build de l'image Docker sans push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer redéployer dessus), github-release (crée une Release GitHub avec changelog auto-généré à partir des PRs mergées), puis notify-portainer — envoie une requête au webhook de redeploy de Portainer si le secret de dépôt PORTAINER_WEBHOOK_URL est configuré (sinon Portainer se resynchronise simplement à son prochain polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret GitHub PORTAINER_WEBHOOK_URL.

Auth (apps/api)

Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpOnly.

  • POST /auth/signup{ firstName, lastName, email, password } → crée le foyer (house) et le profil (user_profiles) en une transaction, pose le cookie de session, renvoie le profil (201)
  • POST /auth/login{ email, password } → pose le cookie de session, renvoie le profil (200) ; message d'erreur volontairement générique (401) que ce soit l'email ou le mot de passe qui soit incorrect
  • POST /auth/logout — efface le cookie (204)
  • GET /auth/me — profil courant, nécessite le cookie de session (401 sinon)

Mots de passe hachés avec argon2. Le hash est indépendant du foyer : un profil crée toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas encore implémenté).

argon2 : version pinnée à 0.31.2, pas de ^. La version 0.45.1 (dernière au moment de l'écriture) segfault au runtime sur au moins une configuration Windows — reproduit de façon stable (bash sandboxé, bash non-sandboxé, PowerShell), alors que 0.31.2 fonctionne parfaitement avec la même API. Si tu montes la version, revérifie concrètement (argon2.hash(...) dans un node -e) avant de merger, un pnpm build qui passe ne suffit pas à détecter un crash runtime.

Les tests (Mocha) tournent avec un coût argon2 réduit (NODE_ENV=test, voir auth.service.ts) — le coût par défaut est volontairement élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI provisionne un vrai Postgres de service (.github/workflows/ci.yml) et exécute prisma migrate deploy avant les tests.

Les tests automatisés et pnpm dev:api partagent la même base Postgres locale. Lancer pnpm test vide user_profiles/house (TRUNCATE ... CASCADE, voir test-support/reset-db.ts) — si tu es en train de tester manuellement à la main (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle efface tes données de test sans prévenir. Pas un bug, juste à savoir.

Planning (apps/api)

  • GET /planning/current — nécessite le cookie de session (401 sinon). Renvoie le planning du foyer de l'utilisateur connecté qui couvre la date du jour (Planning dont start_date <= aujourd'hui <= finish_date), items inclus avec leur recette résolue en { id, name } — ou null s'il n'y en a aucun (foyer sans planning en cours, ou profil sans foyer). null est une réponse valide (200), pas une erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul batch-cooking », voir specs/batch-cooking-architecture.md, reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
  • Type de réponse partagé : PlanningView (packages/shared/src/types/planning.ts), consommé tel quel par apps/web.

Détail de AsyncRequestHandler/wrapAsyncHandler (packages/express-tools) — premier endpoint à combiner requireAuth/AuthLocals avec un handler async, ce qui a mis au jour une contrainte générique trop stricte, corrigée à la source : specs/backend-architecture.md.

Données de référence — régimes & allergènes (apps/api)

  • GET /reference/diets — liste des régimes alimentaires (Diet, 5 valeurs seedées).
  • GET /reference/allergies — liste des allergènes sélectionnables, { id, name } (le nom vient de Category.name — la table allergy elle-même ne porte pas de nom, voir schema.prisma — chaque allergène = une Category + une unique Allergy sous cette catégorie).

Les deux sont publics (pas de requireAuth) : ce sont des données de référence, pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant qu'un compte (donc une session) n'existe.

Données seedées via apps/api/prisma/seed.ts (pnpm --filter api prisma:seed, ou automatiquement après prisma migrate reset — config prisma.seed dans package.json). La logique réelle (listes + upsert idempotent) vit dans src/db/reference-seed-data.ts, partagée avec test-support/reset-db.ts : chaque test repart d'une base avec ces données de référence, pas de tables vides — nécessaire pour tester dietId/allergyIds sur de vraies lignes.

Diet.name et Category.name sont @unique — ajouté à ce schéma (pas dans le doc 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.

Allergies vs intolérances (retour fonctionnel, pas dans le doc spec d'origine) : Category.kind (AllergenKindALLERGY | INTOLERANCE) classe chaque allergène. Seuls Gluten et Sulfites sont en INTOLERANCE (réaction non-immunitaire documentée) ; les 12 autres en ALLERGY (réaction immunitaire classique). Classifié par substance, pas par utilisateur — un même foyer ne peut pas déclarer "allergie au lait" pour un membre et "intolérance au lait" pour un autre ; a suffi pour le besoin exprimé, à revoir si ça devient un problème réel. GET /reference/allergies renvoie kind dans chaque AllergyView ; PATCH /profile/allergies ne change pas (une seule liste d'IDs, kind ne sert qu'à grouper l'affichage côté client).

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.tsprofile.service.ts le réutilise aussi.

Page de connexion / inscription (apps/web)

  • src/api/client.tsApiClient (classe, instance unique exportée apiClient) : enveloppe fetch vers l'API (credentials: "include", requis pour que le cookie de session httpOnly parte/revienne — l'API et le front sont sur des origines différentes). URL configurable via VITE_API_URL (voir .env.example).
  • src/features/auth/AuthContext.tsx — état d'auth global ; appelle GET /auth/me au chargement pour restaurer la session depuis le cookie.
  • src/features/auth/RequireAuth.tsx / RedirectIfAuthenticated.tsx — gardes de route (react-router-dom) : / exige d'être connecté, /login et /signup redirigent vers / si on l'est déjà.
  • src/pages/{Login,Signup,Home}Page.tsx — validation client instantanée via les schémas zod partagés (packages/shared), erreurs API traduites via ErrorMessageService (voir ci-dessous).

Détail de l'organisation complète (dossiers, routing, SCSS/theming) : specs/frontend-architecture.md.

Accueil, sidebar & sections (apps/web)

Une fois connecté, l'utilisateur atterrit sur src/layouts/AppLayout.tsx — sidebar (nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et <Outlet /> pour la route active — montée une seule fois comme route parente de tout l'espace authentifié (App.tsx), pas dupliquée par page. src/pages/HomePage.tsx (routée sur /) affiche le planning de la semaine du foyer (GET /planning/current, voir plus haut) avec ses états chargement/erreur/vide/rempli ; Recettes et Liste de courses n'ont pas encore de backend dédié et rendent pour l'instant le même composant ComingSoonPageFoyer & profil (src/pages/HouseholdPage.tsx), lui, est une vraie page (voir section suivante). Détail complet (pourquoi une seule route parente, pourquoi un composant stub partagé) : specs/frontend-architecture.md.

Parcours profil — foyer, régime, allergènes (apps/web)

  • src/features/profile/HouseNameField, DietSelect, AllergySelect : champs contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien eux-mêmes), partagés par les deux surfaces ci-dessous. AllergySelect utilise une grille de cases à cocher dans un <fieldset>/<legend> plutôt qu'un <select multiple> — bien plus repérable/tapable, notamment sur mobile. Prend un legend en prop (pas un libellé fixe interne) : le même composant est rendu deux fois par chaque page consommatrice — une fois pour les allergies (AllergyView.kind === "ALLERGY"), une fois pour les intolérances ("INTOLERANCE") — les deux listes filtrées côté client à partir d'un seul GET /reference/allergies, mais la sélection (allergyIds) reste une seule liste d'IDs partagée entre les deux groupes (une seule PATCH /profile/allergies).
  • src/pages/onboarding/ — wizard de 3 écrans lancé une fois juste après l'inscription (OnboardingHouseholdPageOnboardingDietPageOnboardingAllergensPage, routes /onboarding/{foyer,regime,allergenes}). Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes top-level RequireAuth, pas nichées sous AppLayout : wizard plein écran sans sidebar, même langage visuel que /login//signup.
  • src/pages/HouseholdPage.tsx (routée sur /foyer) — mêmes réglages, modifiables à tout moment. Hot saving (retour fonctionnel) : pas de bouton "Enregistrer", chaque section sauvegarde automatiquement peu après la dernière modification — nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms, pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le handler onChange de chaque champ, jamais depuis un useEffect générique qui observerait la valeur — un tel effect se déclencherait aussi au chargement initial (quand le GET peuple le même state), sans moyen propre de distinguer "vient d'être chargé" de "vient d'être modifié par l'utilisateur".

Piège trouvé en testant dans le navigateur : RedirectIfAuthenticated (garde de /login//signup) réagissait à chaque changement de user, pas seulement à la vérification initiale — un navigate() explicite dans le gestionnaire de soumission d'un formulaire qu'elle protège (ex. SignupPage après signup(), qui met user à jour) entre alors en course avec le propre <Navigate> de la garde. Invisible tant que les deux ciblaient "/", devenu un vrai bug dès que SignupPage a dû rediriger ailleurs (/onboarding/foyer). Fix : la décision de redirection est verrouillée une seule fois, au moment où isLoading passe à false, plus jamais réévaluée après.

Autre piège, même méthode : HouseholdPage initialisait le régime affiché depuis useAuth().user.dietId (un instantané jamais rafraîchi après une modification faite directement via apiClient, qui ne touche pas AuthContext) — revenait à l'ancienne valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la page fetch son propre profil frais (apiClient.me()) au montage, et AuthContext.refreshUser() (nouveau) est appelé après une sauvegarde réussie du régime pour que le reste de l'app reste cohérent aussi.

Tests Cypress (apps/web/cypress/e2e/*.cy.ts) : mockent l'API via cy.intercept plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de l'API est couvert par la suite Mocha d'apps/api (contre une vraie base).

Cypress ne peut pas tourner en local dans un environnement Windows sandboxé : Chromium/Electron headless plante au lancement du process GPU (GPU process isn't usable), reproductible sur main aussi bien que sur une branche de feature — pas un problème introduit par une modification du code. pnpm --filter web e2e fonctionne normalement en CI (GitHub Actions) et sur une machine de dev classique ; dans cet environnement précis, vérifier manuellement via le serveur de dev (pnpm dev:web + pnpm dev:api en local, pas le conteneur Docker — voir Déploiement — qui sert le frontend buildé, pas le serveur de dev Vite).

Gestion des erreurs (API ↔ web)

Contrat d'erreurs partagé via packages/shared (ErrorCode, énumération numérique groupée par famille — 4000 validation, 401x auth, 404x not found, 500x interne — et ApiErrorResponse) : l'API renvoie toujours { code, message, details? } (message en anglais, dev-facing — jamais affiché tel quel), et le client traduit code en libellé français via i18next (ErrorMessageService, apps/web/src/services/error-message.service.tsapps/web/src/locales/fr/translation.json). Côté API, ErrorHandlerService (packages/error-tools) et createErrorMiddleware (packages/express-tools) centralisent la transformation de toute erreur levée en réponse HTTP conforme — aucune valeur ErrorCode codée en dur nulle part (toujours ErrorCode.XXX, y compris dans les mocks Cypress).

Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) : specs/error-handling.md.

Le profil authentifié (requireAuth) passe par res.locals.userProfile (typé via AuthLocals), pas par une augmentation du namespace global Express — voir specs/backend-architecture.md pour le détail et le pourquoi.

packages/shared fournit aussi assertIsNever (vérification d'exhaustivité de switch/if-chain sur une union, erreur de compilation si un cas est oublié) — voir specs/backend-architecture.md.

i18n

i18next + react-i18next — tout le texte affiché (formulaires, boutons, erreurs) vient de fichiers de locale JSON (apps/web/src/locales/<lng>/translation.json), jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (fr) ; en ajouter une est une question de fichier de locale, pas de code. Détail : specs/frontend-architecture.md.

Données de test (faker.js)

apps/api utilise @faker-js/faker pour toutes les données de test dans test/*.test.ts (Mocha) — jamais de nom/email qui ressemble à une vraie personne en dur dans un fixture.