Tests + docs: onboarding/foyer Cypress coverage, specs updates (step 6/6)

- apps/web/cypress/e2e/onboarding.cy.ts — parcours complet (rempli et
  entièrement skippé) signup → 3 étapes → home, mêmes conventions
  cy.intercept que le reste.
- apps/web/cypress/e2e/household.cy.ts — /foyer : préremplissage, et
  sauvegarde indépendante de chacune des 3 sections.
- specs/frontend-architecture.md : nouvelle section "Parcours profil —
  foyer, régime, allergènes" (diagramme mermaid, les deux bugs de state
  trouvés en testant dans le navigateur), arborescence et namespaces
  i18n à jour.
- README.md : nouvelle section "Parcours profil — foyer, régime,
  allergènes", section sidebar mise à jour (Foyer & profil n'est plus
  un stub).

Cypress lui-même ne peut pas tourner en local dans ce sandbox (voir la
note existante dans le README) — vérifié via `tsc --noEmit` sur les
specs + parcours manuel complet dans le navigateur (les deux à travers
les 5 commits précédents de cette feature).

Clôt la feature profil/foyer/régime/allergènes (6 commits, cette PR) :
seed+référence -> endpoints foyer/profil -> composants partagés ->
wizard d'inscription -> page /foyer -> ce commit.
This commit is contained in:
Nicolas 2026-08-16 23:42:54 +02:00
parent 5d88e28810
commit 9722f4a27b
4 changed files with 346 additions and 22 deletions

View file

@ -258,17 +258,54 @@ Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — s
`<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`, `Liste de
courses` et `Foyer & profil` n'ont pas encore de backend dédié et rendent pour
l'instant le même composant `ComingSoonPage`. Détail complet (pourquoi une seule
route parente, pourquoi un composant stub partagé) :
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 `ComingSoonPage``Foyer & 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](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
## 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.
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage`
`OnboardingAllergensPage`, 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 trois réglages,
modifiables à tout moment, chaque section (foyer/régime/allergènes) avec son
propre bouton "Enregistrer" (3 ressources API indépendantes).
**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/`) : `smoke.cy.ts` + `auth.cy.ts` +
`home-planning.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 les suites
Mocha/Cucumber d'`apps/api` (contre une vraie base).
`home-planning.cy.ts` + `onboarding.cy.ts` + `household.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 les suites Mocha/Cucumber 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

View file

@ -0,0 +1,94 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: 2,
};
describe("Household & profile settings (/foyer)", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez Alice" },
});
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [
{ id: 1, name: "Omnivore" },
{ id: 2, name: "Végétarien" },
],
});
cy.intercept("GET", "**/reference/allergies", {
statusCode: 200,
body: [
{ id: 1, name: "Arachides" },
{ id: 2, name: "Gluten" },
],
});
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
});
it("loads the current household name, regime and allergens", () => {
cy.visit("/foyer");
cy.get("#houseName").should("have.value", "Chez Alice");
cy.get("#diet").should("have.value", "2");
cy.contains("label", "Gluten").find("input[type=checkbox]").should("be.checked");
cy.contains("label", "Arachides").find("input[type=checkbox]").should("not.be.checked");
});
it("saves the household name independently of the other sections", () => {
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez les Martin" },
}).as("renameHouse");
cy.visit("/foyer");
cy.get("#houseName").clear();
cy.get("#houseName").type("Chez les Martin");
cy.get("#houseName")
.closest("form")
.within(() => cy.contains("button", "Enregistrer").click());
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" });
cy.get("#houseName").closest("form").contains("Enregistré ✓").should("be.visible");
});
it("saves the regime independently of the other sections", () => {
cy.intercept("PATCH", "**/profile/diet", {
statusCode: 200,
body: { ...authenticatedProfile, dietId: 1 },
}).as("updateDiet");
cy.visit("/foyer");
cy.get("#diet").select("Omnivore");
cy.get("#diet")
.closest("form")
.within(() => cy.contains("button", "Enregistrer").click());
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 });
cy.get("#diet").closest("form").contains("Enregistré ✓").should("be.visible");
});
it("saves the allergen selection independently of the other sections", () => {
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1, 2] }).as(
"updateAllergies",
);
cy.visit("/foyer");
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
cy.contains("fieldset", "Allergies").within(() => {
cy.contains("button", "Enregistrer").click();
});
cy.wait("@updateAllergies")
.its("request.body")
.should("deep.equal", { allergyIds: [2, 1] });
});
});

View file

@ -0,0 +1,129 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
// real API behavior against a real database).
const signupResponse = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
describe("Onboarding wizard (household → regime → allergens)", () => {
it("walks through all three steps after signup and lands on the home", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup");
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
});
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez Alice" },
}).as("renameHouse");
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [
{ id: 1, name: "Omnivore" },
{ id: 2, name: "Végétarien" },
],
});
cy.intercept("PATCH", "**/profile/diet", {
statusCode: 200,
body: { ...signupResponse, dietId: 2 },
}).as("updateDiet");
cy.intercept("GET", "**/reference/allergies", {
statusCode: 200,
body: [
{ id: 1, name: "Arachides" },
{ id: 2, name: "Gluten" },
],
});
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(
"updateAllergies",
);
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/signup");
cy.get("#firstName").type("Alice");
cy.get("#lastName").type("Martin");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.contains("button", "Créer mon profil").click();
cy.wait("@signup");
// Step 1/3 — household name, prefilled with the auto-generated default.
cy.url().should("include", "/onboarding/foyer");
cy.contains("Étape 1 sur 3").should("be.visible");
cy.get("#houseName").should("have.value", "Foyer de Alice");
cy.get("#houseName").clear();
cy.get("#houseName").type("Chez Alice");
cy.contains("button", "Continuer").click();
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez Alice" });
// Step 2/3 — dietary regime.
cy.url().should("include", "/onboarding/regime");
cy.contains("Étape 2 sur 3").should("be.visible");
cy.get("#diet").select("Végétarien");
cy.contains("button", "Continuer").click();
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 });
// Step 3/3 — allergens/intolerances, then finish.
cy.url().should("include", "/onboarding/allergenes");
cy.contains("Étape 3 sur 3").should("be.visible");
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
cy.contains("button", "Terminer").click();
cy.wait("@updateAllergies")
.its("request.body")
.should("deep.equal", { allergyIds: [1] });
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
cy.contains("h1", "Planning de la semaine").should("be.visible");
});
it("lets every step be skipped without changing anything", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse });
cy.intercept("GET", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
});
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Foyer de Alice" },
}).as("renameHouse");
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as(
"updateDiet",
);
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as(
"updateAllergies",
);
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/signup");
cy.get("#firstName").type("Alice");
cy.get("#lastName").type("Martin");
cy.get("#email").type("alice@example.com");
cy.get("#password").type("correct-horse-battery-staple");
cy.contains("button", "Créer mon profil").click();
cy.url().should("include", "/onboarding/foyer");
cy.contains("button", "Continuer").click();
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Foyer de Alice" });
cy.url().should("include", "/onboarding/regime");
cy.contains("button", "Continuer").click();
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null });
cy.url().should("include", "/onboarding/allergenes");
cy.contains("button", "Terminer").click();
cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] });
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
});
});

View file

@ -14,15 +14,18 @@ apps/web/src/
├── i18n/
│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
├── locales/
│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, household.*)
│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, onboarding.*, household.*)
├── services/
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
├── features/
│ └── auth/ # tout ce qui concerne l'authentification
│ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout)
│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
│ └── auth-form.scss # styles partagés par LoginPage et SignupPage
│ ├── auth/ # tout ce qui concerne l'authentification
│ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser)
│ │ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
│ │ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
│ │ └── auth-form.scss # styles partagés par LoginPage et SignupPage
│ └── profile/ # champs du parcours foyer/régime/allergènes, voir plus bas
│ ├── HouseNameField.tsx / DietSelect.tsx / AllergySelect.tsx
│ └── profile-forms.scss # styles partagés par les trois
├── layouts/
│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas
├── pages/
@ -30,7 +33,11 @@ apps/web/src/
│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
│ ├── HomePage.tsx + HomePage.scss # planning de la semaine (routée sur "/")
│ ├── ComingSoonPage.tsx + .scss # placeholder partagé par les sections sans backend encore
│ ├── RecipesPage.tsx / ShoppingListPage.tsx / HouseholdPage.tsx # fines enveloppes autour de ComingSoonPage
│ ├── RecipesPage.tsx / ShoppingListPage.tsx # fines enveloppes autour de ComingSoonPage
│ ├── HouseholdPage.tsx + .scss # foyer/régime/allergènes, éditable à tout moment (routée sur "/foyer")
│ └── onboarding/ # wizard d'inscription (foyer → régime → allergènes), voir plus bas
│ ├── OnboardingHouseholdPage.tsx / OnboardingDietPage.tsx / OnboardingAllergensPage.tsx
│ └── onboarding.scss # styles partagés par les trois
├── styles/
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
@ -82,6 +89,11 @@ flowchart TB
(redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant
que la vérification initiale est en cours, pour éviter un flash de contenu suivi
d'une redirection.
- **`RedirectIfAuthenticated` verrouille sa décision une seule fois** (au moment où
`isLoading` passe à `false`), au lieu de réagir à chaque changement de `user`
bug trouvé en construisant le wizard d'inscription (voir plus bas) : un
`navigate()` explicite dans le formulaire qu'elle protège entrait en course avec
son propre `<Navigate>`, invisible tant que les deux ciblaient "/".
---
@ -110,12 +122,62 @@ README racine).
### Sections sans backend — `ComingSoonPage`
`Recettes`, `Liste de courses` et `Foyer & profil` n'ont pas encore de backend
dédié (seul `/planning/current` existe, voir le README). Chacune a néanmoins sa
propre route/page (`RecipesPage.tsx`, etc. — choix délibéré pour que construire la
vraie fonctionnalité plus tard soit réécrire un fichier dédié, pas éclater une
route générique), mais toutes rendent le même composant `ComingSoonPage`
(`title`/`description`) pour éviter de tripler un même bloc de markup.
`Recettes` et `Liste de courses` n'ont pas encore de backend dédié (seuls
`/planning/current` et le parcours foyer/profil ci-dessous existent, voir le
README). Chacune a néanmoins sa propre route/page (`RecipesPage.tsx`, etc. — choix
délibéré pour que construire la vraie fonctionnalité plus tard soit réécrire un
fichier dédié, pas éclater une route générique), mais toutes rendent le même
composant `ComingSoonPage` (`title`/`description`) pour éviter de tripler un même
bloc de markup.
---
## Parcours profil — foyer, régime, allergènes
Deux surfaces, mêmes composants de champ (`features/profile/`) :
```mermaid
flowchart LR
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/foyer"]
OB1 --> OB2["/onboarding/regime"]
OB2 --> OB3["/onboarding/allergenes"]
OB3 --> HOME["/ (home)"]
SIDEBAR["Sidebar : Foyer & profil"] --> SETTINGS["/foyer (HouseholdPage)"]
```
- **`pages/onboarding/`** — wizard de 3 écrans, lancé une seule fois juste après
l'inscription. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
wizard plein écran sans sidebar (`onboarding.scss`, même langage visuel que
`/login`/`/signup`, délibérément un fichier à part plutôt qu'un import de
`auth-form.scss` — même choix que `HomePage.scss` avant elle, voir plus haut).
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante — pas de
bouton "Passer" séparé, une valeur "aucune"/vide *est* le skip.
- **`pages/HouseholdPage.tsx`** (routée `/foyer`, dans `AppLayout`) — les mêmes
trois réglages, éditables à tout moment. Trois sections, trois boutons
"Enregistrer" indépendants (3 ressources API distinctes : `PATCH /house/current`,
`/profile/diet`, `/profile/allergies`).
- **`features/profile/`** — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
contrôlés, "dumb" (reçoivent `diets`/`allergies` en props plutôt que de les
fetcher). `AllergySelect` est un `<fieldset>`/`<legend>` + grille de cases à
cocher, pas un `<select multiple>` — bien plus repérable/tapable, notamment sur
mobile (voir la note Capacitor plus haut).
### Deux bugs de state trouvés en testant dans le navigateur
1. **Course entre `navigate()` et `RedirectIfAuthenticated`** — voir la note sur
`RedirectIfAuthenticated` plus haut. `SignupPage` doit maintenant rediriger vers
`/onboarding/foyer`, pas `/`, ce qui a rendu visible une course de state
auparavant invisible.
2. **`user.dietId` périmé sur `/foyer`** — `HouseholdPage` initialisait le régime
affiché depuis `useAuth().user.dietId`, un instantané d'`AuthContext` jamais
rafraîchi après une modification faite directement via `apiClient` (qui ne
touche pas le contexte). Une navigation SPA aller-retour sans rechargement
complet ré-affichait donc l'ancienne valeur après une sauvegarde. Fix :
`HouseholdPage` fetch son propre profil frais (`apiClient.me()`) au montage
plutôt que de dépendre du contexte, et `AuthContext.refreshUser()` (nouvelle
méthode, re-fetch `GET /auth/me`) est appelée après une sauvegarde réussie du
régime — pour que le reste de l'app (pas seulement cette page) reste cohérent.
---
@ -146,7 +208,9 @@ JSON, jamais codé en dur dans un composant.
namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
`auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation,
déconnexion — `AppLayout`), `home.*` (planning), `recipes.*` / `shoppingList.*`
/ `household.*` (copie des pages stub, voir `ComingSoonPage` plus haut).
(copie des pages stub, voir `ComingSoonPage` plus haut), `onboarding.*` (wizard
d'inscription) et `household.*` (titre + `form.*`, champs partagés par le wizard
et `/foyer`).
- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
- Ajouter une langue : créer `locales/<lng>/translation.json` avec les mêmes clés,
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.