From f1fefc1f3837d0d09916c957f1e7760f28b0a6ff Mon Sep 17 00:00:00 2001 From: kyuno053 <31762247+kyuno053@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:17 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20s=C3=A9pare=20les=20tests=20Cypress?= =?UTF-8?q?=20en=20parcours=20utilisateur=20/=20layout=20/=20composants=20?= =?UTF-8?q?(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .github/workflows/ci.yml | 5 + apps/web/cypress.config.ts | 46 ++- .../cypress/component/CheckboxOption.cy.tsx | 140 +++++++++ apps/web/cypress/component/RadioOption.cy.tsx | 151 ++++++++++ apps/web/cypress/e2e/account.cy.ts | 50 +--- apps/web/cypress/e2e/account.feature | 34 +++ apps/web/cypress/e2e/account.ts | 32 +++ apps/web/cypress/e2e/auth.cy.ts | 165 ----------- apps/web/cypress/e2e/auth.feature | 63 +++++ apps/web/cypress/e2e/auth.ts | 78 +++++ apps/web/cypress/e2e/household-settings.cy.ts | 124 +------- .../cypress/e2e/household-settings.feature | 64 +++++ apps/web/cypress/e2e/household-settings.ts | 74 +++++ apps/web/cypress/e2e/layout.cy.ts | 267 ++++++++++++++++++ apps/web/cypress/e2e/login-smoke.feature | 12 - apps/web/cypress/e2e/login-smoke.ts | 21 -- apps/web/cypress/e2e/onboarding.cy.ts | 148 ---------- apps/web/cypress/e2e/onboarding.feature | 67 +++++ apps/web/cypress/e2e/onboarding.ts | 41 +++ apps/web/cypress/e2e/planning-page.cy.ts | 19 +- apps/web/cypress/e2e/preferences.cy.ts | 33 +-- apps/web/cypress/e2e/preferences.feature | 23 ++ apps/web/cypress/e2e/preferences.ts | 40 +++ apps/web/cypress/e2e/recipe-form.cy.ts | 178 ------------ apps/web/cypress/e2e/recipe-form.feature | 69 +++++ apps/web/cypress/e2e/recipe-form.ts | 159 +++++++++++ apps/web/cypress/e2e/recipes.cy.ts | 44 +-- apps/web/cypress/e2e/recipes.feature | 33 +++ apps/web/cypress/e2e/recipes.ts | 103 +++++++ apps/web/cypress/e2e/sidebar.cy.ts | 4 +- apps/web/cypress/e2e/user-preferences.cy.ts | 23 +- apps/web/cypress/e2e/user-preferences.feature | 17 ++ apps/web/cypress/e2e/user-preferences.ts | 15 + apps/web/cypress/support/component-index.html | 9 + apps/web/cypress/support/component.ts | 15 + apps/web/cypress/support/profile.ts | 33 +++ .../support/step_definitions/common.steps.ts | 191 +++++++++++++ .../household-mutations.steps.ts | 63 +++++ .../profile-mutations.steps.ts | 21 ++ .../step_definitions/reference-data.steps.ts | 33 +++ apps/web/package.json | 4 +- pnpm-lock.yaml | 133 +++++++++ 42 files changed, 2049 insertions(+), 795 deletions(-) create mode 100644 apps/web/cypress/component/CheckboxOption.cy.tsx create mode 100644 apps/web/cypress/component/RadioOption.cy.tsx create mode 100644 apps/web/cypress/e2e/account.feature create mode 100644 apps/web/cypress/e2e/account.ts delete mode 100644 apps/web/cypress/e2e/auth.cy.ts create mode 100644 apps/web/cypress/e2e/auth.feature create mode 100644 apps/web/cypress/e2e/auth.ts create mode 100644 apps/web/cypress/e2e/household-settings.feature create mode 100644 apps/web/cypress/e2e/household-settings.ts create mode 100644 apps/web/cypress/e2e/layout.cy.ts delete mode 100644 apps/web/cypress/e2e/login-smoke.feature delete mode 100644 apps/web/cypress/e2e/login-smoke.ts delete mode 100644 apps/web/cypress/e2e/onboarding.cy.ts create mode 100644 apps/web/cypress/e2e/onboarding.feature create mode 100644 apps/web/cypress/e2e/onboarding.ts create mode 100644 apps/web/cypress/e2e/preferences.feature create mode 100644 apps/web/cypress/e2e/preferences.ts delete mode 100644 apps/web/cypress/e2e/recipe-form.cy.ts create mode 100644 apps/web/cypress/e2e/recipe-form.feature create mode 100644 apps/web/cypress/e2e/recipe-form.ts create mode 100644 apps/web/cypress/e2e/recipes.feature create mode 100644 apps/web/cypress/e2e/recipes.ts create mode 100644 apps/web/cypress/e2e/user-preferences.feature create mode 100644 apps/web/cypress/e2e/user-preferences.ts create mode 100644 apps/web/cypress/support/component-index.html create mode 100644 apps/web/cypress/support/component.ts create mode 100644 apps/web/cypress/support/profile.ts create mode 100644 apps/web/cypress/support/step_definitions/common.steps.ts create mode 100644 apps/web/cypress/support/step_definitions/household-mutations.steps.ts create mode 100644 apps/web/cypress/support/step_definitions/profile-mutations.steps.ts create mode 100644 apps/web/cypress/support/step_definitions/reference-data.steps.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2a7d18..f17379c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,3 +104,8 @@ jobs: # explicitly so `cypress run` finds it. - run: pnpm --filter web exec cypress install - run: pnpm --filter web e2e + # No dev server needed here — Cypress spins up its own Vite dev + # server internally for component testing (see cypress.config.ts's + # `component.devServer`), unlike `e2e` above which needs the real app + # running first. + - run: pnpm --filter web cy:run:component diff --git a/apps/web/cypress.config.ts b/apps/web/cypress.config.ts index 20fd5f4..a05626b 100644 --- a/apps/web/cypress.config.ts +++ b/apps/web/cypress.config.ts @@ -1,26 +1,32 @@ import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor"; import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild"; import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; +import { devServer } from "@cypress/vite-dev-server"; import { defineConfig } from "cypress"; +import viteConfig from "./vite.config"; + +// Disable GPU for headless/sandboxed environments (e.g. CI containers) where +// no GPU device is available — needed by both e2e and component testing, +// each of which has its own independent `setupNodeEvents`. +function disableGpu(on: Cypress.PluginEvents) { + on("before:browser:launch", (browser, launchOptions) => { + if (browser.family === "chromium") { + launchOptions.args.push("--disable-gpu", "--no-sandbox"); + } + return launchOptions; + }); +} export default defineConfig({ e2e: { baseUrl: "http://localhost:5173", - // Experimental — testing whether real Gherkin .feature files work with - // this preprocessor version (see experiment/cucumber-cypress). Only one - // throwaway feature exists right now (login.feature); the rest of the - // suite is still plain .cy.ts, matched by the default `**/*.cy.ts` - // pattern alongside `**/*.feature`. + // User journeys, Gherkin-driven (see docs/testing.md once written) live + // alongside plain layout-focused `.cy.ts` specs here — both matched by + // this pattern. Generic component tests are separate, see `component` + // below. specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"], async setupNodeEvents(on, config) { - // Disable GPU for headless/sandboxed environments (e.g. CI containers) - // where no GPU device is available. - on("before:browser:launch", (browser, launchOptions) => { - if (browser.family === "chromium") { - launchOptions.args.push("--disable-gpu", "--no-sandbox"); - } - return launchOptions; - }); + disableGpu(on); await addCucumberPreprocessorPlugin(on, config); on( @@ -33,4 +39,18 @@ export default defineConfig({ return config; }, }, + + // Mounts one generic UI component at a time (components/ui/*), no + // router/backend involved — separate from the e2e suite above, which + // always exercises a full routed page. Reuses the app's own vite.config.ts + // (same React plugin, same Sass setup) rather than duplicating it. + component: { + specPattern: "cypress/component/**/*.cy.tsx", + devServer(devServerConfig) { + return devServer({ ...devServerConfig, viteConfig }); + }, + setupNodeEvents(on) { + disableGpu(on); + }, + }, }); diff --git a/apps/web/cypress/component/CheckboxOption.cy.tsx b/apps/web/cypress/component/CheckboxOption.cy.tsx new file mode 100644 index 0000000..494448b --- /dev/null +++ b/apps/web/cypress/component/CheckboxOption.cy.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { CheckboxOption } from "../../src/components/ui/Checkbox"; + +// First real component test — mounts CheckboxOption in isolation (no +// router, no backend), unlike everything under cypress/e2e/ which always +// visits a full routed page. See cypress.config.ts's `component` block. + +describe("CheckboxOption", () => { + it("renders its label content", () => { + cy.mount( + {}}> + Végétarien + , + ); + + cy.contains("label", "Végétarien").should("be.visible"); + }); + + it("reflects the checked prop on the native input, and the is-selected class", () => { + cy.mount( + {}}> + Végétarien + , + ); + cy.get("input[type=checkbox]").should("not.be.checked"); + cy.get("label").should("not.have.class", "is-selected"); + + cy.mount( + {}}> + Végétarien + , + ); + cy.get("input[type=checkbox]").should("be.checked"); + cy.get("label").should("have.class", "is-selected"); + }); + + it("calls onChange with the toggled value when clicked", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Végétarien + , + ); + + cy.get("input[type=checkbox]").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", true); + }); + + it("is a controlled component — stays checked only while the parent says so", () => { + // A tiny stateful wrapper, since CheckboxOption itself takes no + // internal state — this is what actually exercises the checked/onChange + // contract the way a real caller (AllergySelect, the theme picker…) + // would. + function Wrapper() { + const [checked, setChecked] = useState(false); + return ( + + Végétarien + + ); + } + cy.mount(); + + cy.get("input[type=checkbox]").should("not.be.checked").click(); + cy.get("input[type=checkbox]").should("be.checked"); + }); + + it("calls onChange with false when clicking while already checked", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Végétarien + , + ); + + cy.get("input[type=checkbox]").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", false); + }); + + it("merges the caller's className with the container layout, alongside is-selected", () => { + cy.mount( + {}} className="allergy-select__option"> + Végétarien + , + ); + cy.get("label") + .should("have.class", "allergy-select__option") + .and("not.have.class", "is-selected"); + + cy.mount( + {}} className="allergy-select__option"> + Végétarien + , + ); + cy.get("label").should("have.class", "allergy-select__option").and("have.class", "is-selected"); + }); + + it("has no className at all when the caller doesn't pass one", () => { + cy.mount( + {}}> + Végétarien + , + ); + // `[className, checked && "is-selected"].filter(Boolean).join(" ")` with + // both falsy collapses to "" — worth pinning down since a stray + // "false"/"null" string in `class` would be a real (if harmless-looking) + // regression. + cy.get("label").should("have.attr", "class", ""); + }); + + it("toggles when the click lands on the label text, not just the input itself", () => { + // The label wraps the input (native HTML forwards the click), which is + // what actually makes the whole row clickable — not just a tiny + // checkbox hitbox. This is real browser behavior, not something the + // component's own code implements, but it's exactly the contract the + // "selectable card" look (global.scss) relies on, so it's worth pinning + // down here rather than trusting it silently. + const onChange = cy.stub().as("onChange"); + cy.mount( + + Végétarien + , + ); + + cy.contains("label", "Végétarien").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", true); + }); + + it("marks the check-mark decoration as aria-hidden, so screen readers only announce the checkbox itself", () => { + cy.mount( + {}}> + Végétarien + , + ); + cy.get("span.check-mark").should("have.attr", "aria-hidden", "true"); + }); +}); diff --git a/apps/web/cypress/component/RadioOption.cy.tsx b/apps/web/cypress/component/RadioOption.cy.tsx new file mode 100644 index 0000000..d80b6ce --- /dev/null +++ b/apps/web/cypress/component/RadioOption.cy.tsx @@ -0,0 +1,151 @@ +import { useState } from "react"; +import { RadioOption } from "../../src/components/ui/Radio"; + +// The `type="radio"` sibling of CheckboxOption.cy.tsx — same "selectable +// card" markup, but exercised for what actually differs about a radio +// input: the mandatory `name`/`value` pair and the mutually-exclusive +// group behavior that's the whole reason to reach for radio over checkbox. + +describe("RadioOption", () => { + it("renders its label content", () => { + cy.mount( + {}}> + Sombre + , + ); + + cy.contains("label", "Sombre").should("be.visible"); + }); + + it("sets the native input's name and value", () => { + cy.mount( + {}}> + Sombre + , + ); + + cy.get("input[type=radio]") + .should("have.attr", "name", "theme") + .and("have.attr", "value", "dark"); + }); + + it("reflects the checked prop on the native input, and the is-selected class", () => { + cy.mount( + {}}> + Sombre + , + ); + cy.get("input[type=radio]").should("not.be.checked"); + cy.get("label").should("not.have.class", "is-selected"); + + cy.mount( + {}}> + Sombre + , + ); + cy.get("input[type=radio]").should("be.checked"); + cy.get("label").should("have.class", "is-selected"); + }); + + it("calls onChange with its own value when clicked while unchecked", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.get("input[type=radio]").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", "dark"); + }); + + it("does not fire onChange again when clicking a radio that's already checked", () => { + // Native radio inputs only emit a `change` event when their checked + // state actually flips — clicking an already-selected option in a + // group is a no-op, unlike a checkbox which always toggles. + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.get("input[type=radio]").click(); + + cy.get("@onChange").should("not.have.been.called"); + }); + + it("toggles when the click lands on the label text, not just the input itself", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.contains("label", "Sombre").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", "dark"); + }); + + it("merges the caller's className with the container layout, alongside is-selected", () => { + cy.mount( + {}} + className="theme-select__option" + > + Sombre + , + ); + cy.get("label").should("have.class", "theme-select__option").and("have.class", "is-selected"); + }); + + it("marks the check-mark decoration as aria-hidden, so screen readers only announce the radio itself", () => { + cy.mount( + {}}> + Sombre + , + ); + cy.get("span.check-mark").should("have.attr", "aria-hidden", "true"); + }); + + it("behaves as a mutually-exclusive group when several options share the same name", () => { + // A stateful wrapper mirroring UserPreferencesPage's theme picker — the + // one real caller — mounting 3 RadioOptions that share `name="theme"` + // and one `value` of state between them. + function ThemeGroup() { + const [theme, setTheme] = useState<"system" | "light" | "dark">("system"); + return ( + <> + + Système + + + Clair + + + Sombre + + + ); + } + cy.mount(); + + cy.contains("label", "Système").should("have.class", "is-selected"); + cy.contains("label", "Clair").should("not.have.class", "is-selected"); + cy.contains("label", "Sombre").should("not.have.class", "is-selected"); + + cy.contains("label", "Sombre").click(); + + cy.contains("label", "Sombre").should("have.class", "is-selected"); + cy.contains("label", "Système").should("not.have.class", "is-selected"); + cy.contains("label", "Clair").should("not.have.class", "is-selected"); + // The native `name` grouping also keeps the browser's own radio + // semantics honest — only one input in the group can be `:checked`. + cy.get("input[type=radio]:checked").should("have.length", 1); + }); +}); diff --git a/apps/web/cypress/e2e/account.cy.ts b/apps/web/cypress/e2e/account.cy.ts index 65a79da..832d19e 100644 --- a/apps/web/cypress/e2e/account.cy.ts +++ b/apps/web/cypress/e2e/account.cy.ts @@ -1,6 +1,9 @@ -import { ErrorCode } from "@batch-cooking/shared"; - -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The account-deletion journey (wrong password, success, cancel) moved to +// account.feature — this file now only covers what's left: passive display. const authenticatedProfile = { id: 1, @@ -24,45 +27,4 @@ describe("Account settings (/parametres/compte)", () => { cy.contains("Martin").should("be.visible"); cy.contains("alice@example.com").should("be.visible"); }); - - it("shows an error and keeps the session when the password is wrong", () => { - cy.intercept("DELETE", "**/auth/me", { - statusCode: 401, - body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid password" }, - }).as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.get("#deleteAccountPassword").type("wrong-password"); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteAccount"); - cy.contains("Email ou mot de passe incorrect").should("be.visible"); - cy.url().should("include", "/parametres/compte"); - }); - - it("deletes the account and returns to the login page", () => { - cy.intercept("DELETE", "**/auth/me", { statusCode: 204 }).as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.get("#deleteAccountPassword").type("correct-horse-battery-staple"); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteAccount") - .its("request.body") - .should("deep.equal", { password: "correct-horse-battery-staple" }); - cy.url().should("include", "/login"); - }); - - it("cancels the deletion without calling the API", () => { - cy.intercept("DELETE", "**/auth/me").as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.contains("button", "Annuler").click(); - - cy.contains("button", "Confirmer la suppression").should("not.exist"); - cy.get("@deleteAccount.all").should("have.length", 0); - }); }); diff --git a/apps/web/cypress/e2e/account.feature b/apps/web/cypress/e2e/account.feature new file mode 100644 index 0000000..7cedfa4 --- /dev/null +++ b/apps/web/cypress/e2e/account.feature @@ -0,0 +1,34 @@ +Feature: Account deletion + As a signed-in user + I want to permanently delete my account + So that I stay in control of my data + + Background: + Given I am signed in as "Alice" "Martin" + + Scenario: Shows an error and keeps the session when the password is wrong + Given the account deletion request will fail because the credentials are invalid + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I fill in the "deleteAccountPassword" field with "wrong-password" + And I click the button "Confirmer la suppression" + Then the account deletion request should have been made + And I should see "Email ou mot de passe incorrect" + And the URL should include "/parametres/compte" + + Scenario: Deletes the account and returns to the login page + Given the account deletion request will succeed + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I fill in the "deleteAccountPassword" field with "correct-horse-battery-staple" + And I click the button "Confirmer la suppression" + Then the account deletion request should have been made with password "correct-horse-battery-staple" + And the URL should include "/login" + + Scenario: Cancels the deletion without calling the API + Given the account deletion request is being watched + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I click the button "Annuler" + Then I should not see "Confirmer la suppression" + And the account deletion request should not have been made diff --git a/apps/web/cypress/e2e/account.ts b/apps/web/cypress/e2e/account.ts new file mode 100644 index 0000000..a3f0163 --- /dev/null +++ b/apps/web/cypress/e2e/account.ts @@ -0,0 +1,32 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; +import { ErrorCode } from "@batch-cooking/shared"; + +Given("the account deletion request will fail because the credentials are invalid", () => { + cy.intercept("DELETE", "**/auth/me", { + statusCode: 401, + body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid password" }, + }).as("deleteAccount"); +}); + +Given("the account deletion request will succeed", () => { + cy.intercept("DELETE", "**/auth/me", { statusCode: 204 }).as("deleteAccount"); +}); + +Given("the account deletion request is being watched", () => { + cy.intercept("DELETE", "**/auth/me").as("deleteAccount"); +}); + +Then("the account deletion request should have been made", () => { + cy.wait("@deleteAccount"); +}); + +Then( + "the account deletion request should have been made with password {string}", + (password: string) => { + cy.wait("@deleteAccount").its("request.body").should("deep.equal", { password }); + }, +); + +Then("the account deletion request should not have been made", () => { + cy.get("@deleteAccount.all").should("have.length", 0); +}); diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts deleted file mode 100644 index e0baebd..0000000 --- a/apps/web/cypress/e2e/auth.cy.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ErrorCode } from "@batch-cooking/shared"; - -// Mocks the API via cy.intercept — this job doesn't run a live backend (see -// .github/workflows/ci.yml), and it keeps these specs focused on frontend -// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber -// suites against a real database. - -describe("Signup", () => { - it("creates a profile and starts the onboarding wizard (regime/household/allergens)", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - // The onboarding wizard's first step (see onboarding.cy.ts for the full - // walkthrough) is the regime step, which fetches the reference list. - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("POST", "**/auth/signup", { - statusCode: 201, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: null, - dietId: null, - }, - }).as("signup"); - - 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"); - // Not the home page directly — signup hands off to the onboarding - // wizard first (RedirectIfAuthenticated no longer applies here, it's a - // RequireAuth-gated route of its own, see App.tsx). - cy.url().should("include", "/onboarding/regime"); - cy.contains("Étape 1 sur 3").should("be.visible"); - }); - - it("shows a client-side validation error without calling the API", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup").as("signup"); - - cy.visit("/signup"); - cy.get("#firstName").type("A"); - cy.get("#lastName").type("B"); - cy.get("#email").type("a@example.com"); - cy.get("#password").type("short"); - cy.contains("button", "Créer mon profil").click(); - - cy.contains("8 caractères minimum").should("be.visible"); - cy.get("@signup.all").should("have.length", 0); - }); - - it("shows the API's error when the email is already taken", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup", { - statusCode: 409, - body: { code: ErrorCode.EMAIL_ALREADY_IN_USE, message: "Email already in use" }, - }).as("signup"); - - 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"); - cy.contains("Cet email est déjà utilisé").should("be.visible"); - }); -}); - -describe("Login", () => { - it("logs in and lands on the home page", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - cy.intercept("POST", "**/auth/login", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }).as("login"); - - cy.visit("/login"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Se connecter").click(); - - cy.wait("@login"); - cy.contains("Bonjour Alice").should("be.visible"); - }); - - it("shows an error on invalid credentials", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/login", { - statusCode: 401, - body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" }, - }).as("login"); - - cy.visit("/login"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("wrong-password"); - cy.contains("button", "Se connecter").click(); - - cy.wait("@login"); - cy.contains("Email ou mot de passe incorrect").should("be.visible"); - }); -}); - -describe("Already authenticated", () => { - it("redirects away from /login to the home page", () => { - cy.intercept("GET", "**/auth/me", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - - cy.visit("/login"); - cy.url().should("not.include", "/login"); - cy.contains("Bonjour Alice").should("be.visible"); - }); - - it("logs out and returns to the login page", () => { - cy.intercept("GET", "**/auth/me", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); - - cy.visit("/"); - // "Se déconnecter" lives inside the account menu, opened by clicking - // the greeting button — see AppLayout.tsx's AccountMenu. - cy.contains("button", "Bonjour Alice").click(); - cy.contains("button", "Se déconnecter").click(); - - cy.wait("@logout"); - cy.url().should("include", "/login"); - }); -}); diff --git a/apps/web/cypress/e2e/auth.feature b/apps/web/cypress/e2e/auth.feature new file mode 100644 index 0000000..dc3763a --- /dev/null +++ b/apps/web/cypress/e2e/auth.feature @@ -0,0 +1,63 @@ +Feature: Signup and login + As a visitor + I want to create a profile or log into an existing one + So that I can access my household's batch-cooking planning + + Background: + Given I am not signed in + + Scenario: Signing up creates a profile and starts the onboarding wizard + Given the signup request will succeed + And the diets reference list is empty + When I sign up with: + | firstName | Alice | + | lastName | Martin | + | email | alice@example.com | + | password | correct-horse-battery-staple | + Then the signup request should have been made + And the URL should include "/onboarding/regime" + And I should see "Étape 1 sur 3" + + Scenario: Signing up shows a client-side validation error without calling the API + Given the signup request is being watched + When I sign up with: + | firstName | A | + | lastName | B | + | email | a@example.com | + | password | short | + Then I should see "8 caractères minimum" + And the signup request should not have been made + + Scenario: Signing up shows the API's error when the email is already taken + Given the signup request will fail because the email is already used + When I sign up with: + | firstName | Alice | + | lastName | Martin | + | email | alice@example.com | + | password | correct-horse-battery-staple | + Then the signup request should have been made + And I should see "Cet email est déjà utilisé" + + Scenario: Logging in lands on the home page + Given the login request will succeed + And the planning request returns nothing + When I log in with email "alice@example.com" and password "correct-horse-battery-staple" + Then the login request should have been made + And I should see "Bonjour Alice" + + Scenario: Logging in shows an error on invalid credentials + Given the login request will fail because the credentials are invalid + When I log in with email "alice@example.com" and password "wrong-password" + Then the login request should have been made + And I should see "Email ou mot de passe incorrect" + + Scenario: Logging out returns to the login page + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the planning request returns nothing + And the logout request will succeed + When I visit "/" + And I open the account menu + And I click the button "Se déconnecter" + Then the logout request should have been made + And the URL should include "/login" diff --git a/apps/web/cypress/e2e/auth.ts b/apps/web/cypress/e2e/auth.ts new file mode 100644 index 0000000..d70d1d3 --- /dev/null +++ b/apps/web/cypress/e2e/auth.ts @@ -0,0 +1,78 @@ +import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; +import { ErrorCode } from "@batch-cooking/shared"; + +const signupResponse = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null as number | null, + dietId: null, +}; + +Given("the signup request will succeed", () => { + cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); +}); + +Given("the signup request is being watched", () => { + cy.intercept("POST", "**/auth/signup").as("signup"); +}); + +Given("the signup request will fail because the email is already used", () => { + cy.intercept("POST", "**/auth/signup", { + statusCode: 409, + body: { code: ErrorCode.EMAIL_ALREADY_IN_USE, message: "Email already in use" }, + }).as("signup"); +}); + +When("I sign up with:", (dataTable: DataTable) => { + const { firstName, lastName, email, password } = dataTable.rowsHash(); + cy.visit("/signup"); + cy.get("#firstName").type(firstName); + cy.get("#lastName").type(lastName); + cy.get("#email").type(email); + cy.get("#password").type(password); + cy.contains("button", "Créer mon profil").click(); +}); + +Then("the signup request should have been made", () => { + cy.wait("@signup"); +}); + +Then("the signup request should not have been made", () => { + cy.get("@signup.all").should("have.length", 0); +}); + +Given("the login request will succeed", () => { + cy.intercept("POST", "**/auth/login", { + statusCode: 200, + body: { ...signupResponse, houseId: 1 }, + }).as("login"); +}); + +Given("the login request will fail because the credentials are invalid", () => { + cy.intercept("POST", "**/auth/login", { + statusCode: 401, + body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" }, + }).as("login"); +}); + +When("I log in with email {string} and password {string}", (email: string, password: string) => { + cy.visit("/login"); + cy.get("#email").type(email); + cy.get("#password").type(password); + cy.contains("button", "Se connecter").click(); +}); + +Then("the login request should have been made", () => { + cy.wait("@login"); +}); + +Given("the logout request will succeed", () => { + cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); +}); + +Then("the logout request should have been made", () => { + cy.wait("@logout"); +}); diff --git a/apps/web/cypress/e2e/household-settings.cy.ts b/apps/web/cypress/e2e/household-settings.cy.ts index eb4acd1..e8c5032 100644 --- a/apps/web/cypress/e2e/household-settings.cy.ts +++ b/apps/web/cypress/e2e/household-settings.cy.ts @@ -1,4 +1,10 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The household-management journey (create, join, rename, remove a member, +// delete, leave) moved to household-settings.feature — this file now only +// covers what's left: passive display of each screen's initial state. const adminProfile = { id: 1, @@ -33,51 +39,6 @@ describe("Household settings (/parametres/foyer) — no household yet", () => { cy.contains("Créer un foyer").should("be.visible"); cy.contains("Rejoindre un foyer").should("be.visible"); }); - - it("creates a household", () => { - const createdHouse = { - id: 1, - name: "Chez Alice", - adminId: 1, - inviteCode: "ABCD2345", - members: [{ id: 1, firstName: "Alice", lastName: "Martin" }], - }; - // The page reloads `GET /house/current` right after creating succeeds — - // see the "deletes the household" test above for the same pattern. - let created = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: created ? createdHouse : null }); - }); - cy.intercept("POST", "**/house", (req) => { - created = true; - req.reply({ statusCode: 201, body: createdHouse }); - }).as("createHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#houseName").type("Chez Alice"); - cy.contains("button", "Créer").click(); - - cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); - cy.contains("ABCD2345").should("be.visible"); - }); - - it("joins a household by invite code", () => { - let joined = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: joined ? houseWithTwoMembers : null }); - }); - cy.intercept("POST", "**/house/join", (req) => { - joined = true; - req.reply({ statusCode: 200, body: houseWithTwoMembers }); - }).as("joinHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#inviteCode").type("abcd2345"); - cy.contains("button", "Rejoindre").click(); - - cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" }); - cy.contains("Bob Dupont").should("be.visible"); - }); }); describe("Household settings (/parametres/foyer) — as the admin", () => { @@ -94,68 +55,6 @@ describe("Household settings (/parametres/foyer) — as the admin", () => { cy.contains("Bob Dupont").should("be.visible"); cy.contains("Alice Martin").parent().contains("Admin"); }); - - it("autosaves the household name", () => { - cy.intercept("PATCH", "**/house/current", { - statusCode: 200, - body: { ...houseWithTwoMembers, name: "Chez les Martin" }, - }).as("renameHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#houseName").clear(); - cy.get("#houseName").type("Chez les Martin"); - - cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" }); - cy.contains("Enregistré ✓").should("be.visible"); - }); - - it("removes a member", () => { - // Same reasoning as the "deletes the household" test below — the page - // reloads `GET /house/current` right after the removal succeeds. - let memberRemoved = false; - cy.intercept("GET", "**/house/current", (req) => { - const body = memberRemoved - ? { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] } - : houseWithTwoMembers; - req.reply({ statusCode: 200, body }); - }); - cy.intercept("DELETE", "**/house/members/2", (req) => { - memberRemoved = true; - req.reply({ - statusCode: 200, - body: { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] }, - }); - }).as("removeMember"); - - cy.visit("/parametres/foyer"); - cy.contains("li", "Bob Dupont").contains("button", "Retirer").click(); - - cy.wait("@removeMember"); - cy.contains("Bob Dupont").should("not.exist"); - }); - - it("deletes the household after confirming", () => { - // The page reloads `GET /house/current` right after the delete - // succeeds — this intercept needs to answer differently before/after - // that DELETE, hence the shared mutable flag rather than two static - // `cy.intercept` calls (the later one would just win for every request, - // including the initial page load). - let houseDeleted = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: houseDeleted ? null : houseWithTwoMembers }); - }); - cy.intercept("DELETE", "**/house/current", (req) => { - houseDeleted = true; - req.reply({ statusCode: 204 }); - }).as("deleteHouse"); - - cy.visit("/parametres/foyer"); - cy.contains("button", "Supprimer le foyer").click(); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteHouse"); - cy.contains("Créer un foyer").should("be.visible"); - }); }); describe("Household settings (/parametres/foyer) — as a non-admin member", () => { @@ -173,13 +72,4 @@ describe("Household settings (/parametres/foyer) — as a non-admin member", () cy.contains("button", "Quitter le foyer").should("be.visible"); cy.contains("button", "Supprimer le foyer").should("not.exist"); }); - - it("leaves the household", () => { - cy.intercept("POST", "**/house/leave", { statusCode: 204 }).as("leaveHouse"); - - cy.visit("/parametres/foyer"); - cy.contains("button", "Quitter le foyer").click(); - - cy.wait("@leaveHouse"); - }); }); diff --git a/apps/web/cypress/e2e/household-settings.feature b/apps/web/cypress/e2e/household-settings.feature new file mode 100644 index 0000000..078cdc7 --- /dev/null +++ b/apps/web/cypress/e2e/household-settings.feature @@ -0,0 +1,64 @@ +Feature: Household settings + As a signed-in user + I want to create, join, manage, or leave a household + So that I can share a batch-cooking plan with the people I cook with + + Scenario: Creates a household + Given I am signed in as "Alice" "Martin" + And creating a household will succeed + When I visit "/parametres/foyer" + And I fill in the "houseName" field with "Chez Alice" + And I click the button "Créer" + Then the household creation request should have been made with name "Chez Alice" + And I should see "ABCD2345" + + Scenario: Joins a household by invite code + Given I am signed in as "Alice" "Martin" + And joining a household will succeed + When I visit "/parametres/foyer" + And I fill in the "inviteCode" field with "abcd2345" + And I click the button "Rejoindre" + Then the household join request should have been made with invite code "ABCD2345" + And I should see "Bob Dupont" + + Scenario: Autosaves the household name + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And renaming the household will succeed + When I visit "/parametres/foyer" + And I clear the "houseName" field + And I fill in the "houseName" field with "Chez les Martin" + Then the household rename request should have been made with name "Chez les Martin" + And I should see "Enregistré ✓" + + Scenario: Removes a member + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And removing Bob from the household will succeed + When I visit "/parametres/foyer" + And I click "Retirer" for the member "Bob Dupont" + Then the member removal request should have been made + And I should not see "Bob Dupont" + + Scenario: Deletes the household after confirming + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And deleting the household will succeed + When I visit "/parametres/foyer" + And I click the button "Supprimer le foyer" + And I click the button "Confirmer la suppression" + Then the household deletion request should have been made + And I should see "Créer un foyer" + + Scenario: Leaves the household + Given I am signed in as "Bob" "Dupont" + And my user id is 2 + And my household id is 1 + And the household request returns the two-member household + And leaving the household will succeed + When I visit "/parametres/foyer" + And I click the button "Quitter le foyer" + Then the household leave request should have been made diff --git a/apps/web/cypress/e2e/household-settings.ts b/apps/web/cypress/e2e/household-settings.ts new file mode 100644 index 0000000..6618a1d --- /dev/null +++ b/apps/web/cypress/e2e/household-settings.ts @@ -0,0 +1,74 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +const houseWithTwoMembers = { + id: 1, + name: "Chez Alice", + adminId: 1, + inviteCode: "ABCD2345", + members: [ + { id: 1, firstName: "Alice", lastName: "Martin" }, + { id: 2, firstName: "Bob", lastName: "Dupont" }, + ], +}; + +Given("the household request returns the two-member household", () => { + cy.intercept("GET", "**/house/current", { statusCode: 200, body: houseWithTwoMembers }); +}); + +// Creating/joining a household, and asserting on those two requests, is +// shared with onboarding.feature's household step — see +// cypress/support/step_definitions/household-mutations.steps.ts. + +Given("renaming the household will succeed", () => { + cy.intercept("PATCH", "**/house/current", { + statusCode: 200, + body: { ...houseWithTwoMembers, name: "Chez les Martin" }, + }).as("renameHouse"); +}); + +Given("removing Bob from the household will succeed", () => { + const householdWithoutBob = { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] }; + let memberRemoved = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: memberRemoved ? householdWithoutBob : houseWithTwoMembers }); + }); + cy.intercept("DELETE", "**/house/members/2", (req) => { + memberRemoved = true; + req.reply({ statusCode: 200, body: householdWithoutBob }); + }).as("removeMember"); +}); + +Given("deleting the household will succeed", () => { + let houseDeleted = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: houseDeleted ? null : houseWithTwoMembers }); + }); + cy.intercept("DELETE", "**/house/current", (req) => { + houseDeleted = true; + req.reply({ statusCode: 204 }); + }).as("deleteHouse"); +}); + +Given("leaving the household will succeed", () => { + cy.intercept("POST", "**/house/leave", { statusCode: 204 }).as("leaveHouse"); +}); + +When("I click {string} for the member {string}", (action: string, member: string) => { + cy.contains("li", member).contains("button", action).click(); +}); + +Then("the household rename request should have been made with name {string}", (name: string) => { + cy.wait("@renameHouse").its("request.body").should("deep.equal", { name }); +}); + +Then("the member removal request should have been made", () => { + cy.wait("@removeMember"); +}); + +Then("the household deletion request should have been made", () => { + cy.wait("@deleteHouse"); +}); + +Then("the household leave request should have been made", () => { + cy.wait("@leaveHouse"); +}); diff --git a/apps/web/cypress/e2e/layout.cy.ts b/apps/web/cypress/e2e/layout.cy.ts new file mode 100644 index 0000000..953f197 --- /dev/null +++ b/apps/web/cypress/e2e/layout.cy.ts @@ -0,0 +1,267 @@ +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// Pure layout/style specs — plain Cypress, no Cucumber (see the 3-way test +// split: journeys via Gherkin, generic components via Component Testing, +// and this file's category: the app *shell*'s own structural/visual +// contract, independent of any particular page's content or user journey). +// Regression coverage for the two bugs fixed in #21: the sidebar scrolling +// away with a tall page, and pages not consistently using the available +// width (some stuck to the left edge with a lopsided gap, others correctly +// full-bleed). + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: 1, + dietId: null, +}; + +function interceptAuth() { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); +} + +describe("App shell — sidebar is a fixed-width rail on every page", () => { + beforeEach(() => { + interceptAuth(); + }); + + it("keeps the sidebar at its full expanded width (15rem = 240px), regardless of the page", () => { + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + cy.visit("/"); + cy.get(".app-sidebar") + .should(($sidebar) => { + expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1); + }) + // Flush to the top-left corner of the viewport — nothing pushes it + // down or in, on any page. + .and(($sidebar) => { + const rect = $sidebar[0].getBoundingClientRect(); + expect(rect.top).to.equal(0); + expect(rect.left).to.equal(0); + }); + + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); + cy.visit("/recettes"); + cy.get(".app-sidebar").should(($sidebar) => { + expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1); + }); + }); + + it("shrinks to the icon-only rail width (4.25rem = 68px) once collapsed, and restores 240px when expanded again", () => { + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + cy.visit("/"); + + cy.get(".app-sidebar").should(($sidebar) => { + expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1); + }); + + cy.get(".app-sidebar__collapse-toggle").click(); + cy.get(".app-sidebar").should(($sidebar) => { + expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(68, 1); + }); + + cy.get(".app-sidebar__collapse-toggle").click(); + cy.get(".app-sidebar").should(($sidebar) => { + expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1); + }); + }); +}); + +describe("App shell — viewport-locked height, independent scroll (#21 regression)", () => { + beforeEach(() => { + interceptAuth(); + }); + + it("pins the whole shell to exactly the viewport height, never taller", () => { + cy.viewport(1200, 700); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + cy.visit("/"); + + cy.get(".app-layout").should(($layout) => { + expect($layout[0].getBoundingClientRect().height).to.be.closeTo(700, 1); + expect(getComputedStyle($layout[0]).overflow).to.equal("hidden"); + }); + // The document itself never grows past the viewport — this is the exact + // root cause of the original bug (a tall page scrolling the whole + // document, dragging the sidebar along with it). + cy.document().its("documentElement.scrollHeight").should("be.closeTo", 700, 1); + }); + + it("scrolls only the content area on a page taller than the viewport — the sidebar never moves and the document itself doesn't scroll", () => { + cy.viewport(1200, 700); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + cy.visit("/"); + + // A synthetic spacer, far taller than the viewport — deliberately + // independent of whatever the planning page's own content happens to + // be, since this test is about the shell's scroll contract, not this + // particular page's height. + cy.get(".app-content").then(($content) => { + const spacer = document.createElement("div"); + spacer.style.height = "3000px"; + spacer.setAttribute("data-cy", "scroll-spacer"); + $content[0].appendChild(spacer); + }); + + cy.get(".app-sidebar").then(($sidebar) => { + const topBefore = $sidebar[0].getBoundingClientRect().top; + + cy.get(".app-content").scrollTo("bottom"); + + cy.get(".app-sidebar").should(($again) => { + expect($again[0].getBoundingClientRect().top).to.equal(topBefore); + }); + }); + + // The scroll genuinely happened inside `.app-content`... + cy.get(".app-content").invoke("scrollTop").should("be.greaterThan", 0); + // ...and not on the document/window itself. + cy.window().its("scrollY").should("equal", 0); + }); +}); + +describe("Page width — full-bleed pages vs. centered reading columns (#21 regression)", () => { + beforeEach(() => { + interceptAuth(); + cy.viewport(1600, 900); + }); + + it("stretches the planning page and recipe catalog across the full content width", () => { + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + cy.visit("/"); + assertFillsContentWidth(".planning-page"); + + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); + cy.visit("/recettes"); + assertFillsContentWidth(".recipes-page"); + }); + + it("centers the Liste de courses stub and settings pages, with equal space on both sides", () => { + cy.visit("/liste-de-courses"); + assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem + + cy.visit("/parametres/compte"); + assertCenteredColumn(".settings-page", 896); // max-width: 56rem + }); + + /** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */ + function assertFillsContentWidth(selector: string) { + cy.get(".app-content").then(($content) => { + const style = getComputedStyle($content[0]); + const available = + $content[0].getBoundingClientRect().width - + Number.parseFloat(style.paddingLeft) - + Number.parseFloat(style.paddingRight); + + cy.get(selector).should(($page) => { + expect($page[0].getBoundingClientRect().width).to.be.closeTo(available, 3); + }); + }); + } + + /** Capped at `maxWidthPx` (not stretched full-bleed) and horizontally centered — equal left/right gap within `.app-content`. */ + function assertCenteredColumn(selector: string, maxWidthPx: number) { + cy.get(".app-content").then(($content) => { + const contentRect = $content[0].getBoundingClientRect(); + + cy.get(selector).should(($page) => { + const pageRect = $page[0].getBoundingClientRect(); + expect(pageRect.width).to.be.closeTo(maxWidthPx, 2); + + const leftGap = pageRect.left - contentRect.left; + const rightGap = contentRect.right - pageRect.right; + expect(leftGap).to.be.closeTo(rightGap, 2); + }); + }); + } +}); + +describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => { + beforeEach(() => { + interceptAuth(); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + }); + + it("switches to a full-width horizontal bar, hides the collapse toggle and version tag, and keeps every nav link legible", () => { + cy.viewport(375, 812); + cy.visit("/"); + + cy.get(".app-sidebar").should(($sidebar) => { + const rect = $sidebar[0].getBoundingClientRect(); + expect(rect.width).to.be.closeTo(375, 1); + // A short horizontal bar, not the tall vertical rail — well under the + // desktop rail's own content-driven height. + expect(rect.height).to.be.lessThan(120); + }); + + // Nothing to collapse into on a bar with no rail to shrink. + cy.get(".app-sidebar__collapse-toggle").should("not.be.visible"); + cy.get(".app-sidebar__version").should("not.be.visible"); + + // The main nav must stay fully legible and tappable — icon *and* label + // — falling back to horizontal scroll instead of ever being crushed + // down to unlabeled slivers (see AppLayout.scss's own comment on this + // exact failure mode). + cy.get(".app-sidebar__nav").should(($nav) => { + expect(getComputedStyle($nav[0]).overflowX).to.equal("auto"); + }); + cy.contains(".app-sidebar__nav a", "Planning").find("span.label").should("be.visible"); + cy.contains(".app-sidebar__nav a", "Planning").should(($link) => { + expect($link[0].getBoundingClientRect().width).to.be.greaterThan(40); + }); + + // Contrast: the settings/account toggles' labels *do* collapse to + // icon-only here — there's no room for both a full nav row and full + // text labels on every piece of chrome at once. + cy.contains("button", "Paramètres").find("span.label").should("not.be.visible"); + }); +}); + +describe("Color theme — light/dark tokens actually reach the rendered chrome", () => { + beforeEach(() => { + interceptAuth(); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + }); + + it("renders the sidebar surface and the active nav link in the light palette by default", () => { + cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }); + cy.visit("/"); + + cy.get("html").should("have.attr", "data-theme", "light"); + // --color-surface: #ffffff + cy.get(".app-sidebar").should(($el) => { + expect(getComputedStyle($el[0]).backgroundColor).to.equal("rgb(255, 255, 255)"); + }); + // --color-primary: #2e6b4a, applied as the active nav link's background. + cy.contains(".app-sidebar__nav a", "Planning").should(($link) => { + expect(getComputedStyle($link[0]).backgroundColor).to.equal("rgb(46, 107, 74)"); + }); + }); + + it("switches every themed color to the dark palette when the user's preference is DARK", () => { + cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "DARK" } }); + cy.visit("/"); + + cy.get("html").should("have.attr", "data-theme", "dark"); + // --color-surface: #1c221e + cy.get(".app-sidebar").should(($el) => { + expect(getComputedStyle($el[0]).backgroundColor).to.equal("rgb(28, 34, 30)"); + }); + // --color-primary: #5fae7e + cy.contains(".app-sidebar__nav a", "Planning").should(($link) => { + expect(getComputedStyle($link[0]).backgroundColor).to.equal("rgb(95, 174, 126)"); + }); + // The page background token switches too, not just the sidebar. + cy.get("body").should(($body) => { + // --color-background: #14181a + expect(getComputedStyle($body[0]).backgroundColor).to.equal("rgb(20, 24, 26)"); + }); + }); +}); diff --git a/apps/web/cypress/e2e/login-smoke.feature b/apps/web/cypress/e2e/login-smoke.feature deleted file mode 100644 index 0d0ef5c..0000000 --- a/apps/web/cypress/e2e/login-smoke.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Login screen (throwaway smoke test) - Just checking that Gherkin + Cypress actually work end to end with the - currently installed preprocessor version — not meant to stay in the - suite long-term. - - Scenario: A visitor can type their credentials into the login screen - Given I am not signed in - When I visit "/login" - And I fill in the "email" field with "alice@example.com" - And I fill in the "password" field with "correct-horse-battery-staple" - Then the "email" field should have the value "alice@example.com" - And the "password" field should have the value "correct-horse-battery-staple" diff --git a/apps/web/cypress/e2e/login-smoke.ts b/apps/web/cypress/e2e/login-smoke.ts deleted file mode 100644 index edcf30f..0000000 --- a/apps/web/cypress/e2e/login-smoke.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; - -// Throwaway smoke test — see login-smoke.feature. Steps are deliberately -// minimal/self-contained here rather than shared, since this whole file is -// meant to be deleted once it's proven the preprocessor works. - -Given("I am not signed in", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); -}); - -When("I visit {string}", (path: string) => { - cy.visit(path); -}); - -When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { - cy.get(`#${fieldId}`).type(value); -}); - -Then("the {string} field should have the value {string}", (fieldId: string, value: string) => { - cy.get(`#${fieldId}`).should("have.value", value); -}); diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts deleted file mode 100644 index 93c2af8..0000000 --- a/apps/web/cypress/e2e/onboarding.cy.ts +++ /dev/null @@ -1,148 +0,0 @@ -// 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: null as number | null, - dietId: null, -}; - -/** Signs up and lands on the wizard's first step (regime) — shared setup for every scenario below. */ -function signupAndReachOnboarding() { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); - cy.intercept("GET", /\/planning\?/, { 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"); -} - -describe("Onboarding wizard (regime → foyer → allergens)", () => { - it("walks through all three steps, creating a household on the way, and lands on the home", () => { - cy.intercept("GET", "**/reference/diets", { - statusCode: 200, - body: [ - { id: 1, key: "omnivore" }, - { id: 2, key: "vegetarian" }, - ], - }); - cy.intercept("PATCH", "**/profile/diet", { - statusCode: 200, - body: { ...signupResponse, dietId: 2 }, - }).as("updateDiet"); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("POST", "**/house", { - statusCode: 201, - body: { id: 1, name: "Chez Alice", adminId: 1, inviteCode: "ABCD2345", members: [] }, - }).as("createHouse"); - cy.intercept("GET", "**/reference/allergies", { - statusCode: 200, - body: [ - { id: 1, key: "peanuts", kind: "ALLERGY" }, - { id: 2, key: "gluten", kind: "INTOLERANCE" }, - ], - }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as( - "updateAllergies", - ); - - signupAndReachOnboarding(); - - // Step 1/3 — dietary regime. - cy.url().should("include", "/onboarding/regime"); - cy.contains("Étape 1 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 2/3 — household, optional: creating one here. - cy.url().should("include", "/onboarding/foyer"); - cy.contains("Étape 2 sur 3").should("be.visible"); - cy.get("#houseName").type("Chez Alice"); - cy.contains("button", "Créer").click(); - cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); - - // Step 3/3 — allergens (grouped into two lists) and intolerances, then finish. - cy.url().should("include", "/onboarding/allergenes"); - cy.contains("Étape 3 sur 3").should("be.visible"); - cy.contains("legend", "Allergies").should("be.visible"); - cy.contains("legend", "Intolérances").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 the regime and allergens steps be skipped without changing anything", () => { - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as( - "updateDiet", - ); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as( - "updateAllergies", - ); - - signupAndReachOnboarding(); - - 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/foyer"); - cy.contains("button", "Passer cette étape").click(); - - 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}/`); - }); - - it("lets the household step be completed by joining an existing household instead of creating one", () => { - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("POST", "**/house/join", { - statusCode: 200, - body: { - id: 1, - name: "Chez Bob", - adminId: 2, - inviteCode: "ABCD2345", - members: [ - { id: 1, firstName: "Alice", lastName: "Martin" }, - { id: 2, firstName: "Bob", lastName: "Dupont" }, - ], - }, - }).as("joinHouse"); - cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }); - - signupAndReachOnboarding(); - - cy.contains("button", "Continuer").click(); - cy.url().should("include", "/onboarding/foyer"); - - cy.get("#inviteCode").type("abcd2345"); - cy.contains("button", "Rejoindre").click(); - - cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" }); - cy.url().should("include", "/onboarding/allergenes"); - }); -}); diff --git a/apps/web/cypress/e2e/onboarding.feature b/apps/web/cypress/e2e/onboarding.feature new file mode 100644 index 0000000..c429a82 --- /dev/null +++ b/apps/web/cypress/e2e/onboarding.feature @@ -0,0 +1,67 @@ +Feature: Onboarding wizard + As a newly signed-up user + I want to set my diet, household, and allergens + So that my profile is ready before I start planning meals + + Background: + Given the planning request returns nothing + + Scenario: Walks through all three steps, creating a household on the way, and lands on the home page + Given the diets reference list has options + And selecting the diet will succeed + And the household request returns no household + And creating a household will succeed + And the allergies reference list has options + And updating allergies will succeed + And I have signed up + Then the URL should include "/onboarding/regime" + And I should see "Étape 1 sur 3" + When I select "Végétarien" from the "diet" field + And I click the button "Continuer" + Then the diet update request should have been made with diet id 2 + And the URL should include "/onboarding/foyer" + And I should see "Étape 2 sur 3" + When I fill in the "houseName" field with "Chez Alice" + And I click the button "Créer" + Then the household creation request should have been made with name "Chez Alice" + And the URL should include "/onboarding/allergenes" + And I should see "Étape 3 sur 3" + And I should see the section "Allergies" + And I should see the section "Intolérances" + When I check the checkbox "Arachides" + And I click the button "Terminer" + Then the allergies update request should have been made with allergy id 1 + And the URL should be the home page + And I should see the heading "Planning de la semaine" + + Scenario: Lets the regime and allergens steps be skipped without changing anything + Given the diets reference list is empty + And selecting the diet will succeed + And the household request returns no household + And the allergies reference list is empty + And updating allergies will succeed + And I have signed up + Then the URL should include "/onboarding/regime" + When I click the button "Continuer" + Then the diet update request should have been made with no diet id + And the URL should include "/onboarding/foyer" + When I click the button "Passer cette étape" + Then the URL should include "/onboarding/allergenes" + When I click the button "Terminer" + Then the allergies update request should have been made with no allergy ids + And the URL should be the home page + + Scenario: Lets the household step be completed by joining an existing household instead of creating one + Given the diets reference list is empty + And selecting the diet will succeed + And the household request returns no household + And joining a household will succeed + And the allergies reference list is empty + And updating allergies will succeed + And I have signed up + When I click the button "Continuer" + Then the URL should include "/onboarding/foyer" + When I fill in the "inviteCode" field with "abcd2345" + And I click the button "Rejoindre" + Then the household join request should have been made with invite code "ABCD2345" + And the URL should include "/onboarding/allergenes" diff --git a/apps/web/cypress/e2e/onboarding.ts b/apps/web/cypress/e2e/onboarding.ts new file mode 100644 index 0000000..7fba4e7 --- /dev/null +++ b/apps/web/cypress/e2e/onboarding.ts @@ -0,0 +1,41 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +const signupResponse = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null as number | null, + dietId: null, +}; + +// Signs up and lands on the wizard's first step (regime) — shared setup for +// every scenario in this feature, mirroring `signupAndReachOnboarding` from +// the pre-conversion spec. +Given("I have signed up", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); + + 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"); +}); + +Then("the diet update request should have been made with no diet id", () => { + cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null }); +}); + +Then("the allergies update request should have been made with allergy id {int}", (id: number) => { + cy.wait("@updateAllergies") + .its("request.body") + .should("deep.equal", { allergyIds: [id] }); +}); + +Then("the allergies update request should have been made with no allergy ids", () => { + cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] }); +}); diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index ab13c85..bfb4af3 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -1,6 +1,9 @@ -// 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). +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The logout journey (also reachable from here, via the account menu) moved +// to auth.feature — same behavior, no need to cover it twice. const authenticatedProfile = { id: 1, @@ -47,16 +50,6 @@ describe("Sidebar navigation", () => { cy.url().should("eq", `${Cypress.config().baseUrl}/`); cy.contains("h1", "Planning de la semaine").should("be.visible"); }); - - it("shows the signed-in user's name and lets them log out from the account menu", () => { - cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); - - cy.contains("button", "Bonjour Alice").should("be.visible").click(); - cy.contains("button", "Se déconnecter").click(); - - cy.wait("@logout"); - cy.url().should("include", "/login"); - }); }); describe("Planning grid", () => { diff --git a/apps/web/cypress/e2e/preferences.cy.ts b/apps/web/cypress/e2e/preferences.cy.ts index 65f3111..439bb91 100644 --- a/apps/web/cypress/e2e/preferences.cy.ts +++ b/apps/web/cypress/e2e/preferences.cy.ts @@ -1,4 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The autosave journeys (regime, allergies) moved to preferences.feature — +// this file now only covers the page's initial display. const authenticatedProfile = { id: 1, @@ -52,30 +57,4 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => { cy.visit("/parametres/preferences"); cy.contains("button", "Enregistrer").should("not.exist"); }); - - it("autosaves the regime as soon as it's selected", () => { - cy.intercept("PATCH", "**/profile/diet", { - statusCode: 200, - body: { ...authenticatedProfile, dietId: 1 }, - }).as("updateDiet"); - - cy.visit("/parametres/preferences"); - cy.get("#diet").select("Omnivore"); - - cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 }); - }); - - it("autosaves allergies and intolerances together after checking boxes", () => { - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as( - "updateAllergies", - ); - - cy.visit("/parametres/preferences"); - cy.contains("label", "Arachides").find("input[type=checkbox]").check(); - - cy.wait("@updateAllergies") - .its("request.body") - .should("deep.equal", { allergyIds: [2, 1] }); - cy.contains("Enregistré ✓").should("be.visible"); - }); }); diff --git a/apps/web/cypress/e2e/preferences.feature b/apps/web/cypress/e2e/preferences.feature new file mode 100644 index 0000000..2c23866 --- /dev/null +++ b/apps/web/cypress/e2e/preferences.feature @@ -0,0 +1,23 @@ +Feature: Dietary preferences + As a signed-in user + I want my regime and allergies/intolerances to autosave as I edit them + So that my preferences are always up to date without an explicit save step + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And my diet id is 2 + And the dietary preferences reference data is ready + + Scenario: Autosaves the regime as soon as it's selected + Given selecting the diet will succeed + When I visit "/parametres/preferences" + And I select "Omnivore" from the "diet" field + Then the diet update request should have been made with diet id 1 + + Scenario: Autosaves allergies and intolerances together after checking boxes + Given updating allergies will succeed + When I visit "/parametres/preferences" + And I check the checkbox "Arachides" + Then the allergies update request should have been made with allergy ids 2 and 1 + And I should see "Enregistré ✓" diff --git a/apps/web/cypress/e2e/preferences.ts b/apps/web/cypress/e2e/preferences.ts new file mode 100644 index 0000000..45bd632 --- /dev/null +++ b/apps/web/cypress/e2e/preferences.ts @@ -0,0 +1,40 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +// The page also loads the reference ingredient list + the profile's +// disliked-ingredients selection for `DislikedIngredientsField` — added +// alongside diets/allergies in the same `Promise.all` (see +// PreferencesPage.tsx), so both need mocking here too or that `Promise.all` +// rejects and the whole page renders its error state instead of the form, +// taking `#diet`/the allergy checkboxes down with it. +Given("the dietary preferences reference data is ready", () => { + cy.intercept("GET", "**/reference/diets", { + statusCode: 200, + body: [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, + ], + }); + cy.intercept("GET", "**/reference/allergies", { + statusCode: 200, + body: [ + { id: 1, key: "peanuts", kind: "ALLERGY" }, + { id: 2, key: "gluten", kind: "INTOLERANCE" }, + ], + }); + cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] }); + cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] }); + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); +}); + +// Selecting the diet, updating allergies, and asserting on the diet update +// request are shared with onboarding.feature's regime/allergies steps — see +// cypress/support/step_definitions/profile-mutations.steps.ts. + +Then( + "the allergies update request should have been made with allergy ids {int} and {int}", + (first: number, second: number) => { + cy.wait("@updateAllergies") + .its("request.body") + .should("deep.equal", { allergyIds: [first, second] }); + }, +); diff --git a/apps/web/cypress/e2e/recipe-form.cy.ts b/apps/web/cypress/e2e/recipe-form.cy.ts deleted file mode 100644 index 44ae9fb..0000000 --- a/apps/web/cypress/e2e/recipe-form.cy.ts +++ /dev/null @@ -1,178 +0,0 @@ -// 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 authenticatedProfile = { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, -}; - -const tomato = { - id: 1, - key: "tomato", - icon: "VEGETABLE", - category: "PRODUITS_FRAIS", - subcategory: "LEGUMES", - allergens: [], - diets: [{ id: 2, key: "vegetarian" }], -}; -const egg = { - id: 2, - key: "egg", - icon: "EGG", - category: "CREMERIE_FROMAGE", - subcategory: "OEUFS", - allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], - diets: [], -}; -const carrot = { - id: 3, - key: "carrot", - icon: "VEGETABLE", - category: "PRODUITS_FRAIS", - subcategory: "LEGUMES", - allergens: [], - diets: [{ id: 2, key: "vegetarian" }], -}; - -const diets = [ - { id: 1, key: "omnivore" }, - { id: 2, key: "vegetarian" }, -]; - -function interceptCatalog() { - cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); - cy.intercept("GET", "**/reference/ingredients", { - statusCode: 200, - body: [tomato, egg, carrot], - }); - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); -} - -describe("Recipe form — associating ingredients", () => { - beforeEach(() => { - interceptCatalog(); - }); - - it("adds an ingredient from the picker, fills its quantity/unit, and creates the recipe", () => { - cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id: 42 } }).as("createRecipe"); - - cy.visit("/recettes/nouvelle"); - - cy.get("#recipe-name").type("Salade de tomates"); - - cy.get("input[placeholder='Rechercher un ingrédient…']").type("tomat"); - cy.contains(".ingredient-picker__card", "Tomate").click(); - - // The card disappears from the picker once selected (excludeIds), and - // a row for it appears in the recipe's own ingredient list. - cy.contains(".ingredient-picker__card", "Tomate").should("not.exist"); - cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); - - cy.get(".ingredient-row .ingredient-row__quantity").type("3"); - cy.get(".ingredient-row .ingredient-row__unit").type("unité"); - - cy.contains("button", "Ajouter une étape").click(); - cy.get(".step-list-editor__item textarea").type("Couper les tomates."); - - cy.contains("button", "Enregistrer").should("not.be.disabled").click(); - - cy.wait("@createRecipe") - .its("request.body") - .should("deep.include", { - name: "Salade de tomates", - ingredients: [{ ingredientId: 1, quantity: 3, unit: "unité" }], - }); - cy.url().should("include", "/recettes/42"); - }); - - // Regression test for the exact bug reported: `crypto.randomUUID()` (used - // to mint each ingredient/step draft's client-only React key) throws - // outside a secure context — https, or literally the hostname - // `localhost` — so a LAN IP during on-device testing or a Capacitor - // WebView's `capacitor://` origin hit a black screen with "TypeError: - // crypto.randomUUID is not a function" the instant an ingredient was - // added. Cypress's own origin is secure, so this forces the same failure - // by deleting `crypto.randomUUID` before the app boots — see - // `apps/web/src/lib/client-key.ts`, which replaced it. - it("still works when crypto.randomUUID is unavailable (insecure-context regression)", () => { - cy.visit("/recettes/nouvelle", { - onBeforeLoad(win) { - Object.defineProperty(win.crypto, "randomUUID", { - value: undefined, - configurable: true, - }); - }, - }); - - cy.get("#recipe-name").type("Recette hors contexte sécurisé"); - - cy.contains(".ingredient-picker__card", "Tomate").click(); - cy.contains(".ingredient-picker__card", "Œuf").click(); - - // Both rows rendered with distinct identities — no crash, no React - // "same key" warning silently collapsing one of them. - cy.get(".ingredient-row").should("have.length", 2); - cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); - cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); - - cy.contains("button", "Ajouter une étape").click(); - cy.contains("button", "Ajouter une étape").click(); - cy.get(".step-list-editor__item").should("have.length", 2); - }); - - it("excludes an already-selected ingredient from the picker, and removing it brings it back", () => { - cy.visit("/recettes/nouvelle"); - - cy.contains(".ingredient-picker__card", "Carotte").click(); - cy.contains(".ingredient-picker__card", "Carotte").should("not.exist"); - - cy.contains(".ingredient-row", "Carotte") - .find("button[title='Retirer cet ingrédient']") - .click(); - cy.contains(".ingredient-picker__card", "Carotte").should("be.visible"); - cy.get(".ingredient-row").should("have.length", 0); - }); - - it("preloads an existing recipe's ingredients when editing, and lets you add another", () => { - const existingRecipe = { - id: 7, - name: "Omelette", - description: null, - picture: null, - authorId: 1, - visibility: "PERSONAL", - allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], - diets: [], - isFavorite: false, - ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], - steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], - }; - cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); - cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe"); - - cy.visit("/recettes/7/modifier"); - - cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); - cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", "3"); - - cy.contains(".ingredient-picker__card", "Tomate").click(); - cy.get(".ingredient-row").should("have.length", 2); - cy.get(".ingredient-row .ingredient-row__quantity").last().type("1"); - cy.get(".ingredient-row .ingredient-row__unit").last().type("unité"); - - cy.contains("button", "Enregistrer").click(); - - cy.wait("@updateRecipe") - .its("request.body.ingredients") - .should("deep.equal", [ - { ingredientId: 2, quantity: 3, unit: "unité" }, - { ingredientId: 1, quantity: 1, unit: "unité" }, - ]); - }); -}); diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature new file mode 100644 index 0000000..604cebe --- /dev/null +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -0,0 +1,69 @@ +Feature: Recipe form — associating ingredients + As a signed-in user + I want to build a recipe by picking ingredients, quantities, and steps + So that I can save a complete recipe in one form + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the ingredient and diet catalog is available + + Scenario: Adds an ingredient from the picker, fills its quantity/unit, and creates the recipe + Given creating the recipe will succeed and return id 42 + When I visit "/recettes/nouvelle" + And I fill in the "recipe-name" field with "Salade de tomates" + And I search the ingredient picker for "tomat" + And I select the ingredient "Tomate" from the picker + Then the ingredient "Tomate" should no longer be in the picker + And the recipe should include the ingredient "Tomate" + When I fill in the ingredient's quantity with "3" and unit "unité" + And I add a step + And I fill in the step description with "Couper les tomates." + Then the "Enregistrer" button should not be disabled + When I click the button "Enregistrer" + Then the recipe creation request should have included name "Salade de tomates" and ingredient 1 with quantity 3 and unit "unité" + And the URL should include "/recettes/42" + + # Regression test for the exact bug reported: `crypto.randomUUID()` (used + # to mint each ingredient/step draft's client-only React key) throws + # outside a secure context — https, or literally the hostname `localhost` + # — so a LAN IP during on-device testing or a Capacitor WebView's + # `capacitor://` origin hit a black screen with "TypeError: + # crypto.randomUUID is not a function" the instant an ingredient was + # added. Cypress's own origin is secure, so this forces the same failure + # by deleting `crypto.randomUUID` before the app boots — see + # `apps/web/src/lib/client-key.ts`, which replaced it. + Scenario: Still works when crypto.randomUUID is unavailable (insecure-context regression) + When I visit the new recipe form without a secure random UUID + And I fill in the "recipe-name" field with "Recette hors contexte sécurisé" + And I select the ingredient "Tomate" from the picker + And I select the ingredient "Œuf" from the picker + Then there should be 2 ingredient rows + And the recipe should include the ingredient "Tomate" + And the recipe should include the ingredient "Œuf" + When I add a step + And I add a step + Then there should be 2 step editor items + + Scenario: Excludes an already-selected ingredient from the picker, and removing it brings it back + When I visit "/recettes/nouvelle" + And I select the ingredient "Carotte" from the picker + Then the ingredient "Carotte" should no longer be in the picker + When I remove the ingredient "Carotte" from the recipe + Then the ingredient "Carotte" should be visible in the picker + And there should be 0 ingredient rows + + Scenario: Preloads an existing recipe's ingredients when editing, and lets you add another + Given recipe 7 exists with an egg omelette + And updating recipe 7 will succeed + When I visit "/recettes/7/modifier" + Then the recipe should include the ingredient "Œuf" + And the ingredient's quantity should be "3" + When I select the ingredient "Tomate" from the picker + Then there should be 2 ingredient rows + When I fill in the last ingredient's quantity with "1" and unit "unité" + And I click the button "Enregistrer" + Then the recipe update request should have included these ingredients: + | ingredientId | quantity | unit | + | 2 | 3 | unité | + | 1 | 1 | unité | diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts new file mode 100644 index 0000000..962f16c --- /dev/null +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -0,0 +1,159 @@ +import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +const tomato = { + id: 1, + key: "tomato", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; +const egg = { + id: 2, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], +}; +const carrot = { + id: 3, + key: "carrot", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; + +const diets = [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, +]; + +Given("the ingredient and diet catalog is available", () => { + cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] }); + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); +}); + +Given("creating the recipe will succeed and return id {int}", (id: number) => { + cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id } }).as("createRecipe"); +}); + +When("I visit the new recipe form without a secure random UUID", () => { + cy.visit("/recettes/nouvelle", { + onBeforeLoad(win) { + Object.defineProperty(win.crypto, "randomUUID", { + value: undefined, + configurable: true, + }); + }, + }); +}); + +When("I search the ingredient picker for {string}", (text: string) => { + cy.get("input[placeholder='Rechercher un ingrédient…']").type(text); +}); + +When("I select the ingredient {string} from the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).click(); +}); + +Then("the ingredient {string} should no longer be in the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).should("not.exist"); +}); + +Then("the ingredient {string} should be visible in the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).should("be.visible"); +}); + +Then("the recipe should include the ingredient {string}", (name: string) => { + cy.contains(".ingredient-row__name", name).should("be.visible"); +}); + +Then("there should be {int} ingredient rows", (count: number) => { + cy.get(".ingredient-row").should("have.length", count); +}); + +When("I remove the ingredient {string} from the recipe", (name: string) => { + cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click(); +}); + +When( + "I fill in the ingredient's quantity with {string} and unit {string}", + (quantity: string, unit: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").type(quantity); + cy.get(".ingredient-row .ingredient-row__unit").type(unit); + }, +); + +Then("the ingredient's quantity should be {string}", (quantity: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity); +}); + +When( + "I fill in the last ingredient's quantity with {string} and unit {string}", + (quantity: string, unit: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity); + cy.get(".ingredient-row .ingredient-row__unit").last().type(unit); + }, +); + +When("I add a step", () => { + cy.contains("button", "Ajouter une étape").click(); +}); + +When("I fill in the step description with {string}", (text: string) => { + cy.get(".step-list-editor__item textarea").type(text); +}); + +Then("there should be {int} step editor items", (count: number) => { + cy.get(".step-list-editor__item").should("have.length", count); +}); + +Then( + "the recipe creation request should have included name {string} and ingredient {int} with quantity {int} and unit {string}", + (name: string, ingredientId: number, quantity: number, unit: string) => { + cy.wait("@createRecipe") + .its("request.body") + .should("deep.include", { + name, + ingredients: [{ ingredientId, quantity, unit }], + }); + }, +); + +Given("recipe 7 exists with an egg omelette", () => { + const existingRecipe = { + id: 7, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], + isFavorite: false, + ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], + steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], + }; + cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); +}); + +Given("updating recipe 7 will succeed", () => { + cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe"); +}); + +Then( + "the recipe update request should have included these ingredients:", + (dataTable: DataTable) => { + const expected = dataTable.hashes().map((row) => ({ + ingredientId: Number(row.ingredientId), + quantity: Number(row.quantity), + unit: row.unit, + })); + cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected); + }, +); diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index f7a065c..955f14e 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -1,6 +1,9 @@ -// 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). +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The favorite-toggle and delete-recipe journeys moved to recipes.feature — +// this file now only covers catalog browsing/display. const authenticatedProfile = { id: 1, @@ -200,41 +203,6 @@ describe("Recipe catalog", () => { cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible"); }); - it("toggles a recipe's favorite from the detail panel and reflects it in the table", () => { - cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); - cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); - cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite"); - - cy.visit("/recettes/2"); - - cy.contains(".recipe-table__name", "Omelette") - .find(".recipe-table__fav-mark") - .should("not.exist"); - - cy.get(".favorite-star-button").click(); - cy.wait("@favorite"); - - cy.get(".favorite-star-button").should("have.class", "is-favorite"); - cy.contains(".recipe-table__name", "Omelette").find(".recipe-table__fav-mark").should("exist"); - }); - - it("deletes a recipe after a two-step confirmation, then clears the selection", () => { - cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); - cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); - cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); - - cy.visit("/recettes/2"); - cy.contains(".recipe-detail-panel", "Omelette").should("be.visible"); - - cy.contains(".recipe-detail-panel__danger-button", "Supprimer").click(); - cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click(); - cy.wait("@deleteRecipe"); - - cy.url().should("match", /\/recettes\/?$/); - cy.contains(".recipe-table__name", "Omelette").should("not.exist"); - cy.contains("Sélectionnez une recette dans le tableau").should("be.visible"); - }); - it("links the new-recipe button to the recipe form", () => { cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); diff --git a/apps/web/cypress/e2e/recipes.feature b/apps/web/cypress/e2e/recipes.feature new file mode 100644 index 0000000..18a0ea4 --- /dev/null +++ b/apps/web/cypress/e2e/recipes.feature @@ -0,0 +1,33 @@ +Feature: Managing a recipe from the catalog + As a signed-in user + I want to favorite or delete one of my recipes + So that I can curate my catalog as it grows + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the disliked ingredients list is empty + + Scenario: Toggles a recipe's favorite from the detail panel and reflects it in the table + Given the recipe catalog contains "Omelette" + And recipe 2's detail is available + And toggling recipe 2's favorite will succeed + When I visit "/recettes/2" + Then the recipe "Omelette" should not be marked as favorite + When I click the favorite star + Then the favorite request should have been made + And the favorite star should be marked as favorite + And the recipe "Omelette" should be marked as favorite + + Scenario: Deletes a recipe after a two-step confirmation, then clears the selection + Given the recipe catalog contains "Omelette" + And recipe 2's detail is available + And deleting recipe 2 will succeed + When I visit "/recettes/2" + Then the recipe detail panel heading should be "Omelette" + When I click "Supprimer" in the recipe detail panel + And I confirm the deletion in the recipe detail panel + Then the delete request should have been made + And the URL should match the recipes list + And the recipe "Omelette" should not be visible in the table + And I should see "Sélectionnez une recette dans le tableau" diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts new file mode 100644 index 0000000..02c0e80 --- /dev/null +++ b/apps/web/cypress/e2e/recipes.ts @@ -0,0 +1,103 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" }; + +const omelette = { + id: 2, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [oeufs], + diets: [], + isFavorite: false, +}; + +const omeletteDetail = { + ...omelette, + description: "Une omelette toute simple.", + ingredients: [ + { + ingredient: { + id: 10, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [oeufs], + diets: [], + }, + quantity: 3, + unit: "unité", + }, + ], + steps: [ + { id: 1, description: "Battre les œufs.", picture: null, order: 1 }, + { id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, + ], +}; + +Given("the disliked ingredients list is empty", () => { + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); +}); + +Given("the recipe catalog contains {string}", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); +}); + +Given("recipe 2's detail is available", () => { + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe"); +}); + +Given("toggling recipe 2's favorite will succeed", () => { + cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite"); +}); + +Given("deleting recipe 2 will succeed", () => { + cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); +}); + +Then("the recipe {string} should not be visible in the table", (name: string) => { + cy.contains(".recipe-table__name", name).should("not.exist"); +}); + +Then("the recipe {string} should be marked as favorite", (name: string) => { + cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("exist"); +}); + +Then("the recipe {string} should not be marked as favorite", (name: string) => { + cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("not.exist"); +}); + +Then("the recipe detail panel heading should be {string}", (text: string) => { + cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible"); +}); + +When("I click the favorite star", () => { + cy.get(".favorite-star-button").click(); +}); + +Then("the favorite request should have been made", () => { + cy.wait("@favorite"); +}); + +Then("the favorite star should be marked as favorite", () => { + cy.get(".favorite-star-button").should("have.class", "is-favorite"); +}); + +When("I click {string} in the recipe detail panel", (text: string) => { + cy.contains(".recipe-detail-panel__danger-button", text).click(); +}); + +When("I confirm the deletion in the recipe detail panel", () => { + cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click(); +}); + +Then("the delete request should have been made", () => { + cy.wait("@deleteRecipe"); +}); + +Then("the URL should match the recipes list", () => { + cy.url().should("match", /\/recettes\/?$/); +}); diff --git a/apps/web/cypress/e2e/sidebar.cy.ts b/apps/web/cypress/e2e/sidebar.cy.ts index 2d69946..56d38e3 100644 --- a/apps/web/cypress/e2e/sidebar.cy.ts +++ b/apps/web/cypress/e2e/sidebar.cy.ts @@ -1,4 +1,6 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. const authenticatedProfile = { id: 1, diff --git a/apps/web/cypress/e2e/user-preferences.cy.ts b/apps/web/cypress/e2e/user-preferences.cy.ts index 0c09b1c..5368f72 100644 --- a/apps/web/cypress/e2e/user-preferences.cy.ts +++ b/apps/web/cypress/e2e/user-preferences.cy.ts @@ -1,4 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The theme-switching journey moved to user-preferences.feature — this file +// now only covers the page's initial display (default/saved theme). const authenticatedProfile = { id: 1, @@ -35,20 +40,4 @@ describe("User preferences (/parametres/preferences-utilisateur)", () => { cy.contains("label", "Sombre").find("input[type=radio]").should("be.checked"); cy.get("html").should("have.attr", "data-theme", "dark"); }); - - it("switching theme autosaves and applies immediately, no explicit save button", () => { - cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "SYSTEM" } }); - cy.intercept("PATCH", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }).as( - "updatePreferences", - ); - - cy.visit("/parametres/preferences-utilisateur"); - cy.contains("button", "Enregistrer").should("not.exist"); - - cy.contains("label", "Clair").click(); - - cy.wait("@updatePreferences").its("request.body").should("deep.equal", { theme: "LIGHT" }); - cy.contains("Enregistré ✓").should("be.visible"); - cy.get("html").should("have.attr", "data-theme", "light"); - }); }); diff --git a/apps/web/cypress/e2e/user-preferences.feature b/apps/web/cypress/e2e/user-preferences.feature new file mode 100644 index 0000000..4f4b996 --- /dev/null +++ b/apps/web/cypress/e2e/user-preferences.feature @@ -0,0 +1,17 @@ +Feature: User preferences (theme) + As a signed-in user + I want to switch between system/light/dark theme + So that the app matches my preference, applied immediately + + Background: + Given I am signed in as "Alice" "Martin" + + Scenario: Switching theme autosaves and applies immediately, no explicit save button + Given my saved theme preference is "SYSTEM" + And updating the theme preference will succeed + When I visit "/parametres/preferences-utilisateur" + Then I should not see "Enregistrer" + When I click the radio "Clair" + Then the theme update request should have been made with theme "LIGHT" + And I should see "Enregistré ✓" + And the page theme should be "light" diff --git a/apps/web/cypress/e2e/user-preferences.ts b/apps/web/cypress/e2e/user-preferences.ts new file mode 100644 index 0000000..e888bcd --- /dev/null +++ b/apps/web/cypress/e2e/user-preferences.ts @@ -0,0 +1,15 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +Given("my saved theme preference is {string}", (theme: string) => { + cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme } }); +}); + +Given("updating the theme preference will succeed", () => { + cy.intercept("PATCH", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }).as( + "updatePreferences", + ); +}); + +Then("the theme update request should have been made with theme {string}", (theme: string) => { + cy.wait("@updatePreferences").its("request.body").should("deep.equal", { theme }); +}); diff --git a/apps/web/cypress/support/component-index.html b/apps/web/cypress/support/component-index.html new file mode 100644 index 0000000..aad39e0 --- /dev/null +++ b/apps/web/cypress/support/component-index.html @@ -0,0 +1,9 @@ + + + + + + +
+ + diff --git a/apps/web/cypress/support/component.ts b/apps/web/cypress/support/component.ts new file mode 100644 index 0000000..38f5f07 --- /dev/null +++ b/apps/web/cypress/support/component.ts @@ -0,0 +1,15 @@ +import { mount } from "cypress/react18"; +// Same global stylesheet the real app loads (see src/main.tsx) — generic +// components (Checkbox, Radio, Dialog…) are styled through it, not their +// own scoped CSS, so mounting one without it would test unstyled markup. +import "../../src/styles/global.scss"; + +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount; + } + } +} + +Cypress.Commands.add("mount", mount); diff --git a/apps/web/cypress/support/profile.ts b/apps/web/cypress/support/profile.ts new file mode 100644 index 0000000..a0236ed --- /dev/null +++ b/apps/web/cypress/support/profile.ts @@ -0,0 +1,33 @@ +import type { SafeUserProfile } from "@batch-cooking/shared"; + +/** + * Mutable per-scenario signed-in profile, built up across several `Given` + * steps (see common.steps.ts's "I am signed in as .../my household id + * is.../my diet id is...") before the final `cy.visit` — each step + * re-registers the `GET **\/auth/me` intercept with the updated shape, so + * only the last one (i.e. the fully assembled profile) is ever actually + * requested by the app. Reset before every scenario by the `Before` hook in + * common.steps.ts, so scenarios never leak state into one another. + */ +export let currentProfile: SafeUserProfile | null = null; + +export function resetProfile() { + currentProfile = null; +} + +export function buildProfile(overrides: Partial = {}): SafeUserProfile { + return { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null, + dietId: null, + ...overrides, + }; +} + +export function setCurrentProfile(profile: SafeUserProfile) { + currentProfile = profile; +} diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts new file mode 100644 index 0000000..b428189 --- /dev/null +++ b/apps/web/cypress/support/step_definitions/common.steps.ts @@ -0,0 +1,191 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; +import { buildProfile, currentProfile, resetProfile, setCurrentProfile } from "../profile"; + +// Steps shared across every feature — signing in/out, navigation, and +// generic UI assertions/interactions phrased the same way regardless of +// which page they happen to run against. Anything specific to one feature +// (its own API responses, its own DOM structure) lives in that feature's +// own `.steps.ts` instead — same split as apps/api's +// step-definitions/ (shared "profile already exists" vs. feature-specific +// steps). +// +// Mocks the API via `cy.intercept` — this job doesn't run a live backend +// (see .github/workflows/ci.yml); apps/api's own Mocha/Cucumber suites +// cover real API behavior against a real database. + +Given("I am not signed in", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 401 }); +}); + +// No `Before()` hook for this reset (deliberately) — registering any +// Cucumber hook makes the preprocessor's browser runtime read +// `messages.HookType.{BEFORE,AFTER}_TEST_CASE` to report it, and that enum +// doesn't exist on the older, CommonJS-only `@cucumber/messages` this repo +// is pinned to (see `pnpm.overrides` in package.json, and this branch's +// commit history for why) — every scenario crashed on "Cannot read +// properties of undefined (reading 'BEFORE_TEST_CASE')" the moment this +// file registered one. Resetting right here instead, at the one step every +// profile-building chain always starts with, is equivalent for our +// purposes without needing a hook at all. +Given("I am signed in as {string} {string}", (firstName: string, lastName: string) => { + resetProfile(); + setCurrentProfile( + buildProfile({ + firstName, + lastName, + email: `${firstName.toLowerCase()}@example.com`, + }), + ); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +// Composable with the step above — re-registers the same intercept with an +// updated profile field. Order between these doesn't matter as long as they +// all run before the scenario's `visit`/`When` step: Cypress resolves +// multiple `cy.intercept` calls on the same route by giving the +// most-recently-registered one priority, so the final, fully-assembled +// profile is always what the app actually receives. +Given("my household id is {int}", (houseId: number) => { + if (!currentProfile) { + throw new Error('"my household id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, houseId }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +Given("my diet id is {int}", (dietId: number) => { + if (!currentProfile) { + throw new Error('"my diet id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, dietId }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +Given("my user id is {int}", (id: number) => { + if (!currentProfile) { + throw new Error('"my user id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, id }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +When("I visit {string}", (path: string) => { + cy.visit(path); +}); + +Then("the URL should include {string}", (fragment: string) => { + cy.url().should("include", fragment); +}); + +Then("the URL should not include {string}", (fragment: string) => { + cy.url().should("not.include", fragment); +}); + +Then("the URL should be the home page", () => { + cy.url().should("eq", `${Cypress.config().baseUrl}/`); +}); + +Then("I should see {string}", (text: string) => { + cy.contains(text).should("be.visible"); +}); + +Then("I should see the heading {string}", (text: string) => { + cy.contains("h1", text).should("be.visible"); +}); + +Then("I should not see {string}", (text: string) => { + cy.contains(text).should("not.exist"); +}); + +When("I click the button {string}", (text: string) => { + cy.contains("button", text).click(); +}); + +When("I click the link {string}", (text: string) => { + cy.contains("a", text).click(); +}); + +When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { + cy.get(`#${fieldId}`).type(value); +}); + +When("I clear the {string} field", (fieldId: string) => { + cy.get(`#${fieldId}`).clear(); +}); + +Then("the {string} field should have the value {string}", (fieldId: string, value: string) => { + cy.get(`#${fieldId}`).should("have.value", value); +}); + +When("I select {string} from the {string} field", (value: string, fieldId: string) => { + cy.get(`#${fieldId}`).select(value); +}); + +Then("I should see the section {string}", (legend: string) => { + cy.contains("legend", legend).should("be.visible"); +}); + +Then("the checkbox {string} should be checked", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").should("be.checked"); +}); + +Then("the checkbox {string} should not be checked", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").should("not.be.checked"); +}); + +When("I check the checkbox {string}", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").check(); +}); + +Then("the radio {string} should be checked", (label: string) => { + cy.contains("label", label).find("input[type=radio]").should("be.checked"); +}); + +Then("the radio {string} should not be checked", (label: string) => { + cy.contains("label", label).find("input[type=radio]").should("not.be.checked"); +}); + +When("I click the radio {string}", (label: string) => { + cy.contains("label", label).click(); +}); + +Then("the page should have no theme override", () => { + cy.get("html").should("not.have.attr", "data-theme"); +}); + +Then("the page theme should be {string}", (theme: string) => { + cy.get("html").should("have.attr", "data-theme", theme); +}); + +// Freezes `Date` so "today"/"this week" assertions are deterministic +// instead of depending on the day the suite happens to run. +Given("today is frozen at {string}", (iso: string) => { + cy.clock(new Date(iso), ["Date"]); +}); + +Given("the viewport is {int} by {int}", (width: number, height: number) => { + cy.viewport(width, height); +}); + +Then("the {string} button should not be disabled", (text: string) => { + cy.contains("button", text).should("not.be.disabled"); +}); + +When("I open the account menu", () => { + cy.get(".app-sidebar__account-toggle").click(); +}); + +// Not "**/planning*" — that glob also matches the Vite dev request for +// planning-page.scss. Used by any feature that lands on the home page +// (Planning) but isn't itself testing the planning grid's content. +Given("the planning request returns nothing", () => { + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); +}); + +Given("the household request returns no household", () => { + cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); +}); + +Then("the link {string} should point to {string}", (text: string, href: string) => { + cy.contains("a", text).should("have.attr", "href", href); +}); diff --git a/apps/web/cypress/support/step_definitions/household-mutations.steps.ts b/apps/web/cypress/support/step_definitions/household-mutations.steps.ts new file mode 100644 index 0000000..82c6aa4 --- /dev/null +++ b/apps/web/cypress/support/step_definitions/household-mutations.steps.ts @@ -0,0 +1,63 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +// Shared across household-settings.feature (dedicated create/join scenarios) +// and onboarding.feature (household step of the wizard) — both need to mock +// the create/join mutations and their up-to-date `GET /house/current` +// follow-up the same way. + +const houseWithTwoMembers = { + id: 1, + name: "Chez Alice", + adminId: 1, + inviteCode: "ABCD2345", + members: [ + { id: 1, firstName: "Alice", lastName: "Martin" }, + { id: 2, firstName: "Bob", lastName: "Dupont" }, + ], +}; + +// The page reloads `GET /house/current` right after each mutation below +// succeeds — these intercepts need to answer differently before/after that +// follow-up GET, hence a shared mutable flag rather than a single static +// `cy.intercept` (a later static one would just win for every request, +// including the initial page load). + +Given("creating a household will succeed", () => { + const createdHouse = { + id: 1, + name: "Chez Alice", + adminId: 1, + inviteCode: "ABCD2345", + members: [{ id: 1, firstName: "Alice", lastName: "Martin" }], + }; + let created = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: created ? createdHouse : null }); + }); + cy.intercept("POST", "**/house", (req) => { + created = true; + req.reply({ statusCode: 201, body: createdHouse }); + }).as("createHouse"); +}); + +Given("joining a household will succeed", () => { + let joined = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: joined ? houseWithTwoMembers : null }); + }); + cy.intercept("POST", "**/house/join", (req) => { + joined = true; + req.reply({ statusCode: 200, body: houseWithTwoMembers }); + }).as("joinHouse"); +}); + +Then("the household creation request should have been made with name {string}", (name: string) => { + cy.wait("@createHouse").its("request.body").should("deep.equal", { name }); +}); + +Then( + "the household join request should have been made with invite code {string}", + (code: string) => { + cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: code }); + }, +); diff --git a/apps/web/cypress/support/step_definitions/profile-mutations.steps.ts b/apps/web/cypress/support/step_definitions/profile-mutations.steps.ts new file mode 100644 index 0000000..3774aaf --- /dev/null +++ b/apps/web/cypress/support/step_definitions/profile-mutations.steps.ts @@ -0,0 +1,21 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +// Shared across preferences.feature (dedicated autosave scenarios) and +// onboarding.feature (regime/allergies steps of the wizard) — both need the +// same diet/allergies PATCH mocks and their request-body assertions. + +Given("selecting the diet will succeed", () => { + cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: { dietId: 1 } }).as( + "updateDiet", + ); +}); + +Then("the diet update request should have been made with diet id {int}", (dietId: number) => { + cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId }); +}); + +Given("updating allergies will succeed", () => { + cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as( + "updateAllergies", + ); +}); diff --git a/apps/web/cypress/support/step_definitions/reference-data.steps.ts b/apps/web/cypress/support/step_definitions/reference-data.steps.ts new file mode 100644 index 0000000..732da0e --- /dev/null +++ b/apps/web/cypress/support/step_definitions/reference-data.steps.ts @@ -0,0 +1,33 @@ +import { Given } from "@badeball/cypress-cucumber-preprocessor"; + +// Shared across auth.feature (signup) and onboarding.feature (wizard) — both +// need the diets/allergies reference lists mocked before reaching a step +// that reads them, in either their "has options" or "is empty" shape. + +Given("the diets reference list has options", () => { + cy.intercept("GET", "**/reference/diets", { + statusCode: 200, + body: [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, + ], + }); +}); + +Given("the diets reference list is empty", () => { + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); +}); + +Given("the allergies reference list has options", () => { + cy.intercept("GET", "**/reference/allergies", { + statusCode: 200, + body: [ + { id: 1, key: "peanuts", kind: "ALLERGY" }, + { id: 2, key: "gluten", kind: "INTOLERANCE" }, + ], + }); +}); + +Given("the allergies reference list is empty", () => { + cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 95cb204..a2f80dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,8 @@ "test": "echo \"no unit tests yet\" && exit 0", "cy:open": "cypress open", "cy:run": "cypress run", - "e2e": "start-server-and-test dev http://localhost:5173 cy:run" + "e2e": "start-server-and-test dev http://localhost:5173 cy:run", + "cy:run:component": "cypress run --component" }, "dependencies": { "@batch-cooking/date-tools": "workspace:*", @@ -26,6 +27,7 @@ "devDependencies": { "@badeball/cypress-cucumber-preprocessor": "22.2.0", "@bahmutov/cypress-esbuild-preprocessor": "2.2.8", + "@cypress/vite-dev-server": "5.2.1", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 523baa5..7560769 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@bahmutov/cypress-esbuild-preprocessor': specifier: 2.2.8 version: 2.2.8(esbuild@0.21.5) + '@cypress/vite-dev-server': + specifier: 5.2.1 + version: 5.2.1 '@types/node': specifier: ^22.9.0 version: 22.20.1 @@ -488,6 +491,9 @@ packages: resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==, tarball: https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz} engines: {node: '>= 6'} + '@cypress/vite-dev-server@5.2.1': + resolution: {integrity: sha512-5HEUpB2UjpoBByOPAdTBfeJWHlvyDv3Qz5GuGovoiZnzsZyF9eivWfFiYadFdjXXX8i8kVzibo8heWZg+jigGg==, tarball: https://registry.npmjs.org/@cypress/vite-dev-server/-/vite-dev-server-5.2.1.tgz} + '@cypress/xvfb@1.2.4': resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==, tarball: https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz} @@ -1439,6 +1445,9 @@ packages: resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==, tarball: https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==, tarball: https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz} @@ -1703,6 +1712,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} engines: {node: '>= 8'} + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==, tarball: https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==, tarball: https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz} + engines: {node: '>= 6'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, tarball: https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz} @@ -1857,6 +1873,19 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==, tarball: https://registry.npmjs.org/diff/-/diff-7.0.0.tgz} engines: {node: '>=0.3.1'} + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==, tarball: https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==, tarball: https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==, tarball: https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz} engines: {node: '>=12'} @@ -1907,6 +1936,9 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==, tarball: https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz} engines: {node: '>=8.6'} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, tarball: https://registry.npmjs.org/entities/-/entities-2.2.0.tgz} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==, tarball: https://registry.npmjs.org/entities/-/entities-7.0.1.tgz} engines: {node: '>=0.12'} @@ -2084,6 +2116,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, tarball: https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz} engines: {node: '>=10'} + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==, tarball: https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + flat@5.0.2: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==, tarball: https://registry.npmjs.org/flat/-/flat-5.0.2.tgz} hasBin: true @@ -2546,6 +2582,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==, tarball: https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz} @@ -2779,6 +2819,9 @@ packages: encoding: optional: true + node-html-parser@5.3.3: + resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==, tarball: https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz} + node-releases@2.0.53: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz} engines: {node: '>=18'} @@ -2808,6 +2851,9 @@ packages: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==, tarball: https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz} deprecated: This package is no longer supported. + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, tarball: https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz} engines: {node: '>=0.10.0'} @@ -2846,10 +2892,18 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==, tarball: https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz} engines: {node: '>=10'} @@ -2881,6 +2935,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==, tarball: https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz} engines: {node: '>=0.10.0'} @@ -3720,6 +3778,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz} + engines: {node: '>=12.20'} + yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz} @@ -4098,6 +4160,15 @@ snapshots: tunnel-agent: 0.6.0 uuid: 8.3.2 + '@cypress/vite-dev-server@5.2.1': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + find-up: 6.3.0 + node-html-parser: 5.3.3 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': dependencies: debug: 3.2.7(supports-color@8.1.1) @@ -4881,6 +4952,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -5125,6 +5198,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-what@6.2.2: {} + csstype@3.2.3: {} cypress@13.17.0: @@ -5328,6 +5411,24 @@ snapshots: diff@7.0.0: {} + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -5377,6 +5478,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@2.2.0: {} + entities@7.0.1: {} env-paths@2.2.1: {} @@ -5682,6 +5785,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + flat@5.0.2: {} follow-redirects@1.16.0(debug@4.4.3): @@ -6141,6 +6249,10 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -6360,6 +6472,11 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-html-parser@5.3.3: + dependencies: + css-select: 4.3.0 + he: 1.2.0 + node-releases@2.0.53: {} node-source-walk@7.0.2: @@ -6389,6 +6506,10 @@ snapshots: gauge: 3.0.2 set-blocking: 2.0.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -6427,10 +6548,18 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + p-map@4.0.0: dependencies: aggregate-error: 3.1.0 @@ -6462,6 +6591,8 @@ snapshots: path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -7340,6 +7471,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yup@1.6.1: dependencies: property-expr: 2.0.6