feat(web): migre les specs Cypress vers Cucumber/Gherkin
Les tests e2e (apps/web/cypress/e2e/) étaient de simples specs Cypress (.cy.ts), sans lien avec Cucumber alors qu'apps/api utilise déjà Gherkin pour ses propres tests BDD. Intègre @badeball/cypress-cucumber-preprocessor pour écrire les scénarios utilisateurs en Gherkin des deux côtés, même vocabulaire. - cypress.config.ts : specPattern sur *.feature, wiring du préprocesseur (esbuild bundler + plugin cucumber) - Les 11 fichiers .cy.ts sont remplacés par des paires .feature/.steps.ts (co-localisées, même nom) — conversion complète, comportement équivalent (mêmes intercepts, mêmes assertions) - cypress/support/step_definitions/common.steps.ts : steps partagés entre features (connexion, navigation, assertions génériques de texte/URL/champ) — globaux à toute la suite, réutilisables tels quels - cypress/support/profile.ts : profil du compte "connecté" courant, assemblé au fil de plusieurs Given avant le premier visit/When - README : nouvelle section "Cucumber (apps/web)" (miroir de la section existante pour apps/api), mise à jour des références aux anciens noms de fichiers .cy.ts (déjà obsolètes avant ce changement) Vérification : impossible d'exécuter Cypress dans cet environnement (crash Electron/GPU au lancement, limitation déjà documentée dans le README — reproductible sur main, indépendante de ce changement). À la place : - les 447 steps Gherkin des 11 .feature ont été vérifiés programmatiquement contre les 165 patterns de step enregistrés : 0 non résolu, 0 ambigu - les 11 .feature parsent correctement avec le parser Gherkin officiel (57 scénarios au total) - tous les .steps.ts passent `biome check` (syntaxe + style) sans erreur - CYPRESS_INSTALL_BINARY déjà géré (voir PR précédente) — le binaire est bien présent localement (`cypress verify` OK), donc le blocage est spécifiquement le sandbox GPU de cet environnement, pas l'installation La vraie exécution reste à vérifier via le job `e2e` de la CI GitHub Actions sur cette PR — c'est le chemin déjà documenté dans le README pour cet environnement précis.
This commit is contained in:
parent
c34eaa89d0
commit
20d52aee2d
38 changed files with 3540 additions and 1385 deletions
31
README.md
31
README.md
|
|
@ -101,7 +101,7 @@ pnpm lint # Biome (lint + format check)
|
||||||
pnpm lint:fix # Biome --write
|
pnpm lint:fix # Biome --write
|
||||||
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
||||||
pnpm --filter api test:bdd # tests d'intégration BDD (Cucumber/Gherkin, apps/api)
|
pnpm --filter api test:bdd # tests d'intégration BDD (Cucumber/Gherkin, apps/api)
|
||||||
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
|
pnpm --filter web e2e # tests e2e (Cypress + Cucumber/Gherkin, démarre le serveur dev automatiquement)
|
||||||
pnpm build # build de tous les workspaces
|
pnpm build # build de tous les workspaces
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -132,6 +132,31 @@ qui n'existent pas encore).
|
||||||
> explicitement CommonJS ; les steps/world restent en `.ts` ESM classique et sont
|
> explicitement CommonJS ; les steps/world restent en `.ts` ESM classique et sont
|
||||||
> chargés via `tsx` (`NODE_OPTIONS=--import=tsx`, voir le script `test:bdd`).
|
> chargés via `tsx` (`NODE_OPTIONS=--import=tsx`, voir le script `test:bdd`).
|
||||||
|
|
||||||
|
### Cucumber (apps/web)
|
||||||
|
|
||||||
|
Les scénarios e2e (`apps/web/cypress/e2e/`) sont eux aussi écrits en Gherkin, via
|
||||||
|
[`@badeball/cypress-cucumber-preprocessor`](https://github.com/badeball/cypress-cucumber-preprocessor)
|
||||||
|
— même langage que les tests BDD d'`apps/api` ci-dessus, deux suites différentes
|
||||||
|
(frontend mocké vs backend contre une vraie base) mais un seul vocabulaire pour
|
||||||
|
décrire un scénario utilisateur :
|
||||||
|
|
||||||
|
- `apps/web/cypress/e2e/*.feature` — scénarios en Given/When/Then, un fichier par
|
||||||
|
fonctionnalité (`auth.feature`, `recipes.feature`, `planning-page.feature`, …)
|
||||||
|
- `apps/web/cypress/e2e/*.steps.ts` — steps propres à une feature (co-localisé,
|
||||||
|
même nom que le `.feature` correspondant)
|
||||||
|
- `apps/web/cypress/support/step_definitions/common.steps.ts` — steps partagés par
|
||||||
|
plusieurs features (se connecter, naviguer, assertions génériques de texte/URL/
|
||||||
|
champ) ; un step déjà défini là (ou dans un autre `*.steps.ts`) est réutilisable
|
||||||
|
tel quel dans n'importe quelle feature, pas besoin de le redéfinir
|
||||||
|
- `apps/web/cypress/support/profile.ts` — profil du compte "connecté" courant,
|
||||||
|
construit au fil de plusieurs `Given` (`I am signed in as "..." "..."`, `my
|
||||||
|
household id is ...`) avant le premier `cy.visit`/`When` du scénario
|
||||||
|
|
||||||
|
Pour ajouter un scénario : écrire le `.feature`, réutiliser les steps existants
|
||||||
|
quand c'est possible (`common.steps.ts` ou un autre `*.steps.ts` — les
|
||||||
|
définitions de steps sont globales à toute la suite), sinon en ajouter un nouveau
|
||||||
|
dans le `.steps.ts` de la feature concernée.
|
||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
||||||
|
|
@ -363,8 +388,8 @@ page fetch son propre profil frais (`apiClient.me()`) au montage, et
|
||||||
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
|
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
|
||||||
régime pour que le reste de l'app reste cohérent aussi.
|
régime pour que le reste de l'app reste cohérent aussi.
|
||||||
|
|
||||||
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` +
|
Tests Cypress (`apps/web/cypress/e2e/*.feature`, scénarios Gherkin — voir
|
||||||
`home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` mockent l'API via
|
[Cucumber (apps/web)](#cucumber-appsweb) ci-dessous) mockent l'API via
|
||||||
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
|
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
|
||||||
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
|
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
|
||||||
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une
|
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
|
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild";
|
||||||
|
import createBundler from "@bahmutov/cypress-esbuild-preprocessor";
|
||||||
import { defineConfig } from "cypress";
|
import { defineConfig } from "cypress";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://localhost:5173",
|
baseUrl: "http://localhost:5173",
|
||||||
setupNodeEvents(on) {
|
// Scenarios live in `.feature` files (Gherkin), one per
|
||||||
|
// cypress/e2e/*.cy.ts spec this replaced — see cypress/e2e/README.md.
|
||||||
|
// Step definitions are picked up from the preprocessor's default globs:
|
||||||
|
// co-located `cypress/e2e/<feature-name>/*.ts` for scenario-specific
|
||||||
|
// steps, `cypress/support/step_definitions/*.ts` for steps shared across
|
||||||
|
// features (auth, navigation, generic API mocking).
|
||||||
|
specPattern: "cypress/e2e/**/*.feature",
|
||||||
|
async setupNodeEvents(on, config) {
|
||||||
// Disable GPU for headless/sandboxed environments (e.g. CI containers)
|
// Disable GPU for headless/sandboxed environments (e.g. CI containers)
|
||||||
// where no GPU device is available.
|
// where no GPU device is available.
|
||||||
on("before:browser:launch", (browser, launchOptions) => {
|
on("before:browser:launch", (browser, launchOptions) => {
|
||||||
|
|
@ -12,6 +22,16 @@ export default defineConfig({
|
||||||
}
|
}
|
||||||
return launchOptions;
|
return launchOptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await addCucumberPreprocessorPlugin(on, config);
|
||||||
|
on(
|
||||||
|
"file:preprocessor",
|
||||||
|
createBundler({
|
||||||
|
plugins: [createEsbuildPlugin(config)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return config;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: null,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Account settings (/parametres/compte)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the signed-in profile's identity", () => {
|
|
||||||
cy.visit("/parametres/compte");
|
|
||||||
|
|
||||||
cy.contains("Alice").should("be.visible");
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
40
apps/web/cypress/e2e/account.feature
Normal file
40
apps/web/cypress/e2e/account.feature
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
Feature: Account settings
|
||||||
|
As a signed-in user
|
||||||
|
I want to view my account details and be able to delete my account
|
||||||
|
So that I stay in control of my data
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
|
||||||
|
Scenario: Shows the signed-in profile's identity
|
||||||
|
When I visit "/parametres/compte"
|
||||||
|
Then I should see "Alice"
|
||||||
|
And I should see "Martin"
|
||||||
|
And I should see "alice@example.com"
|
||||||
|
|
||||||
|
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
|
||||||
32
apps/web/cypress/e2e/account.steps.ts
Normal file
32
apps/web/cypress/e2e/account.steps.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
71
apps/web/cypress/e2e/auth.feature
Normal file
71
apps/web/cypress/e2e/auth.feature
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
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: An already signed-in visitor is redirected away from the login page
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And my household id is 1
|
||||||
|
And the planning request returns nothing
|
||||||
|
When I visit "/login"
|
||||||
|
Then the URL should not include "/login"
|
||||||
|
And I should see "Bonjour Alice"
|
||||||
|
|
||||||
|
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"
|
||||||
82
apps/web/cypress/e2e/auth.steps.ts
Normal file
82
apps/web/cypress/e2e/auth.steps.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the diets reference list is empty", () => {
|
||||||
|
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
|
|
||||||
|
|
||||||
const adminProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: null as number | null,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const houseWithTwoMembers = {
|
|
||||||
id: 1,
|
|
||||||
name: "Chez Alice",
|
|
||||||
adminId: 1,
|
|
||||||
inviteCode: "ABCD2345",
|
|
||||||
members: [
|
|
||||||
{ id: 1, firstName: "Alice", lastName: "Martin" },
|
|
||||||
{ id: 2, firstName: "Bob", lastName: "Dupont" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Household settings (/parametres/foyer) — no household yet", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: adminProfile });
|
|
||||||
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers to create or join a household", () => {
|
|
||||||
cy.visit("/parametres/foyer");
|
|
||||||
|
|
||||||
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", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: { ...adminProfile, houseId: 1 } });
|
|
||||||
cy.intercept("GET", "**/house/current", { statusCode: 200, body: houseWithTwoMembers });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the household's name, invite code, and members with an admin badge", () => {
|
|
||||||
cy.visit("/parametres/foyer");
|
|
||||||
|
|
||||||
cy.get("#houseName").should("have.value", "Chez Alice");
|
|
||||||
cy.contains("ABCD2345").should("be.visible");
|
|
||||||
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", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { ...adminProfile, id: 2, firstName: "Bob", lastName: "Dupont", houseId: 1 },
|
|
||||||
});
|
|
||||||
cy.intercept("GET", "**/house/current", { statusCode: 200, body: houseWithTwoMembers });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers to leave the household instead of deleting it", () => {
|
|
||||||
cy.visit("/parametres/foyer");
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
90
apps/web/cypress/e2e/household-settings.feature
Normal file
90
apps/web/cypress/e2e/household-settings.feature
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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: Offers to create or join a household when I have none yet
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And the household request returns no household
|
||||||
|
When I visit "/parametres/foyer"
|
||||||
|
Then I should see "Créer un foyer"
|
||||||
|
And I should see "Rejoindre un foyer"
|
||||||
|
|
||||||
|
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: Shows the household's name, invite code, and members with an admin badge
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And my household id is 1
|
||||||
|
And the household request returns the two-member household
|
||||||
|
When I visit "/parametres/foyer"
|
||||||
|
Then the "houseName" field should have the value "Chez Alice"
|
||||||
|
And I should see "ABCD2345"
|
||||||
|
And I should see "Bob Dupont"
|
||||||
|
And "Alice Martin" should be marked as Admin
|
||||||
|
|
||||||
|
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: Offers to leave the household instead of deleting it, as a non-admin member
|
||||||
|
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
|
||||||
|
When I visit "/parametres/foyer"
|
||||||
|
Then I should see "Quitter le foyer"
|
||||||
|
And I should not see "Supprimer le 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
|
||||||
120
apps/web/cypress/e2e/household-settings.steps.ts
Normal file
120
apps/web/cypress/e2e/household-settings.steps.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("{string} should be marked as Admin", (member: string) => {
|
||||||
|
cy.contains(member).parent().contains("Admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
67
apps/web/cypress/e2e/onboarding.feature
Normal file
67
apps/web/cypress/e2e/onboarding.feature
Normal file
|
|
@ -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"
|
||||||
65
apps/web/cypress/e2e/onboarding.steps.ts
Normal file
65
apps/web/cypress/e2e/onboarding.steps.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: [] });
|
||||||
|
});
|
||||||
|
|
@ -1,163 +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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2026-08-17 is a Monday — frozen via `cy.clock` so "today"/"this week"
|
|
||||||
// assertions are deterministic instead of depending on the day the suite
|
|
||||||
// happens to run.
|
|
||||||
const TODAY = new Date("2026-08-17T09:00:00Z");
|
|
||||||
|
|
||||||
function freezeToday() {
|
|
||||||
cy.clock(TODAY, ["Date"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Sidebar navigation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
|
||||||
cy.visit("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
|
|
||||||
// toggle, not the main nav tested here — are covered by sidebar.cy.ts.
|
|
||||||
it("highlights the current section and navigates between stub pages", () => {
|
|
||||||
cy.contains("nav a", "Planning").should("have.class", "active");
|
|
||||||
|
|
||||||
cy.contains("nav a", "Recettes").click();
|
|
||||||
cy.url().should("include", "/recettes");
|
|
||||||
cy.contains("h1", "Recettes").should("be.visible");
|
|
||||||
cy.contains("nav a", "Recettes").should("have.class", "active");
|
|
||||||
cy.contains("nav a", "Planning").should("not.have.class", "active");
|
|
||||||
|
|
||||||
cy.contains("nav a", "Liste de courses").click();
|
|
||||||
cy.url().should("include", "/liste-de-courses");
|
|
||||||
cy.contains("h1", "Liste de courses").should("be.visible");
|
|
||||||
|
|
||||||
cy.contains("nav a", "Planning").click();
|
|
||||||
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", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Desktop-only design (see the plan/PR description) — wider than
|
|
||||||
// Cypress's default 1000×660 so all 7 day columns fit without the grid's
|
|
||||||
// horizontal scroll hiding the later ones from visibility assertions.
|
|
||||||
cy.viewport(1600, 900);
|
|
||||||
freezeToday();
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an empty grid (every slot just offering '+') when the household has no planning yet", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
cy.wait("@getPlanning").its("request.url").should("include", "date=2026-08-17");
|
|
||||||
|
|
||||||
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
|
||||||
// 5 meal rows × 7 days = 35 empty slots, each just a "+".
|
|
||||||
cy.get(".add-recipe-btn").should("have.length", 35);
|
|
||||||
cy.get(".recipe-chip").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders each recipe in its (day, meal) cell, and highlights today's column", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
items: [
|
|
||||||
{ id: 1, weekDay: "mardi", meal: "diner", recipe: { id: 1, name: "Ratatouille" } },
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
weekDay: "mercredi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
recipe: { id: 2, name: "Curry de lentilles" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
|
|
||||||
cy.contains("th", "Lundi").should("be.visible");
|
|
||||||
cy.contains("th", "Dimanche").should("be.visible");
|
|
||||||
cy.contains(".recipe-chip", "Ratatouille").should("be.visible");
|
|
||||||
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
|
|
||||||
|
|
||||||
// Today (17 août, Lundi) is marked — its column header carries `.today`.
|
|
||||||
cy.contains("th.today .day-date", "17").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a loading state, then an error state when the request fails", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
|
||||||
statusCode: 500,
|
|
||||||
body: { code: 5000, message: "boom" },
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
|
|
||||||
cy.contains("Impossible de charger le planning, réessayez plus tard").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assertions below check the rendered week label/badge, not the intercepted
|
|
||||||
// request count — React StrictMode (see main.tsx) double-invokes effects in
|
|
||||||
// dev, so the `GET /planning` mount effect can fire twice per navigation;
|
|
||||||
// counting exact `cy.wait` calls against that would be flaky, but the
|
|
||||||
// rendered result is the same either way.
|
|
||||||
it("navigates to the next/previous week, re-fetching each time", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
cy.wait("@getPlanning").its("request.url").should("include", "date=2026-08-17");
|
|
||||||
cy.contains("Semaine du 17 au 23 août 2026").should("be.visible");
|
|
||||||
cy.contains("Cette semaine").should("be.visible");
|
|
||||||
|
|
||||||
cy.get(".week-nav__arrow").last().click();
|
|
||||||
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
|
|
||||||
cy.contains("Cette semaine").should("not.exist");
|
|
||||||
|
|
||||||
cy.get(".week-nav__arrow").first().click();
|
|
||||||
cy.contains("Semaine du 17 au 23 août 2026").should("be.visible");
|
|
||||||
cy.get(".week-nav__arrow").first().click();
|
|
||||||
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
cy.wait("@getPlanning");
|
|
||||||
|
|
||||||
cy.contains("button", "Semaine du").click();
|
|
||||||
cy.get(".calendar-popover").should("be.visible");
|
|
||||||
// Picking the 25th (still August, unambiguous in the visible grid)
|
|
||||||
// should jump to the week of the 24th–30th.
|
|
||||||
cy.get(".calendar-grid__day").contains(/^25$/).click();
|
|
||||||
|
|
||||||
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
|
|
||||||
cy.get(".calendar-popover").should("not.exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
102
apps/web/cypress/e2e/planning-page.feature
Normal file
102
apps/web/cypress/e2e/planning-page.feature
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
Feature: Planning page — sidebar navigation and weekly grid
|
||||||
|
As a signed-in user
|
||||||
|
I want to navigate between sections and see my household's weekly planning
|
||||||
|
So that I know what meals are planned this week
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And my household id is 1
|
||||||
|
|
||||||
|
# The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
|
||||||
|
# toggle, not the main nav tested here — are covered by sidebar.feature.
|
||||||
|
Scenario: Highlights the current section and navigates between stub pages
|
||||||
|
Given the planning request returns nothing
|
||||||
|
When I visit "/"
|
||||||
|
Then the nav link "Planning" should be active
|
||||||
|
When I click the nav link "Recettes"
|
||||||
|
Then the URL should include "/recettes"
|
||||||
|
And I should see the heading "Recettes"
|
||||||
|
And the nav link "Recettes" should be active
|
||||||
|
And the nav link "Planning" should not be active
|
||||||
|
When I click the nav link "Liste de courses"
|
||||||
|
Then the URL should include "/liste-de-courses"
|
||||||
|
And I should see the heading "Liste de courses"
|
||||||
|
When I click the nav link "Planning"
|
||||||
|
Then the URL should be the home page
|
||||||
|
And I should see the heading "Planning de la semaine"
|
||||||
|
|
||||||
|
Scenario: Shows the signed-in user's name and lets them log out from the account menu
|
||||||
|
Given 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"
|
||||||
|
|
||||||
|
# Desktop-only design (see the plan/PR description) — wider than Cypress's
|
||||||
|
# default 1000×660 so all 7 day columns fit without the grid's horizontal
|
||||||
|
# scroll hiding the later ones from visibility assertions.
|
||||||
|
Scenario: Shows an empty grid when the household has no planning yet
|
||||||
|
Given the viewport is 1600 by 900
|
||||||
|
And today is frozen at "2026-08-17T09:00:00Z"
|
||||||
|
And the current planning is empty
|
||||||
|
When I visit "/"
|
||||||
|
Then the planning request should have been made for the week of "2026-08-17"
|
||||||
|
And I should see the heading "Planning de la semaine"
|
||||||
|
And the grid should have 35 empty slots
|
||||||
|
And no recipe chips should be shown
|
||||||
|
|
||||||
|
Scenario: Renders each recipe in its (day, meal) cell, and highlights today's column
|
||||||
|
Given the viewport is 1600 by 900
|
||||||
|
And today is frozen at "2026-08-17T09:00:00Z"
|
||||||
|
And the current planning includes:
|
||||||
|
| day | meal | recipe |
|
||||||
|
| mardi | diner | Ratatouille |
|
||||||
|
| mercredi | dejeuner | Curry de lentilles |
|
||||||
|
When I visit "/"
|
||||||
|
Then the day column "Lundi" should be visible
|
||||||
|
And the day column "Dimanche" should be visible
|
||||||
|
And the recipe chip "Ratatouille" should be visible
|
||||||
|
And the recipe chip "Curry de lentilles" should be visible
|
||||||
|
And today's column should show the date "17"
|
||||||
|
|
||||||
|
Scenario: Shows a loading state, then an error state when the request fails
|
||||||
|
Given the viewport is 1600 by 900
|
||||||
|
And today is frozen at "2026-08-17T09:00:00Z"
|
||||||
|
And the current planning request fails
|
||||||
|
When I visit "/"
|
||||||
|
Then I should see "Impossible de charger le planning, réessayez plus tard"
|
||||||
|
|
||||||
|
# Assertions below check the rendered week label/badge, not the intercepted
|
||||||
|
# request count — React StrictMode (see main.tsx) double-invokes effects in
|
||||||
|
# dev, so the `GET /planning` mount effect can fire twice per navigation;
|
||||||
|
# counting exact `cy.wait` calls against that would be flaky, but the
|
||||||
|
# rendered result is the same either way.
|
||||||
|
Scenario: Navigates to the next/previous week, re-fetching each time
|
||||||
|
Given the viewport is 1600 by 900
|
||||||
|
And today is frozen at "2026-08-17T09:00:00Z"
|
||||||
|
And the current planning is empty
|
||||||
|
When I visit "/"
|
||||||
|
Then the planning request should have been made for the week of "2026-08-17"
|
||||||
|
And I should see "Semaine du 17 au 23 août 2026"
|
||||||
|
And I should see "Cette semaine"
|
||||||
|
When I click the next week arrow
|
||||||
|
Then I should see "Semaine du 24 au 30 août 2026"
|
||||||
|
And I should not see "Cette semaine"
|
||||||
|
When I click the previous week arrow
|
||||||
|
Then I should see "Semaine du 17 au 23 août 2026"
|
||||||
|
When I click the previous week arrow
|
||||||
|
Then I should see "Semaine du 10 au 16 août 2026"
|
||||||
|
|
||||||
|
Scenario: Jumps to an arbitrary week by picking a day in the calendar popover
|
||||||
|
Given the viewport is 1600 by 900
|
||||||
|
And today is frozen at "2026-08-17T09:00:00Z"
|
||||||
|
And the current planning is empty
|
||||||
|
When I visit "/"
|
||||||
|
Then the planning request should have been made for the week of "2026-08-17"
|
||||||
|
When I open the week calendar
|
||||||
|
Then the calendar popover should be visible
|
||||||
|
When I pick day 25 in the calendar
|
||||||
|
Then I should see "Semaine du 24 au 30 août 2026"
|
||||||
|
And the calendar popover should be closed
|
||||||
92
apps/web/cypress/e2e/planning-page.steps.ts
Normal file
92
apps/web/cypress/e2e/planning-page.steps.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
Given("the current planning is empty", () => {
|
||||||
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the current planning includes:", (dataTable: DataTable) => {
|
||||||
|
const items = dataTable.hashes().map((row, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
weekDay: row.day,
|
||||||
|
meal: row.meal,
|
||||||
|
recipe: { id: index + 1, name: row.recipe },
|
||||||
|
}));
|
||||||
|
cy.intercept("GET", /\/planning\?/, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
startDate: "2026-08-17T00:00:00.000Z",
|
||||||
|
finishDate: "2026-08-23T00:00:00.000Z",
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the current planning request fails", () => {
|
||||||
|
cy.intercept("GET", /\/planning\?/, {
|
||||||
|
statusCode: 500,
|
||||||
|
body: { code: 5000, message: "boom" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the planning request should have been made for the week of {string}", (date: string) => {
|
||||||
|
cy.wait("@getPlanning").its("request.url").should("include", `date=${date}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the grid should have {int} empty slots", (count: number) => {
|
||||||
|
cy.get(".add-recipe-btn").should("have.length", count);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("no recipe chips should be shown", () => {
|
||||||
|
cy.get(".recipe-chip").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the day column {string} should be visible", (day: string) => {
|
||||||
|
cy.contains("th", day).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the recipe chip {string} should be visible", (name: string) => {
|
||||||
|
cy.contains(".recipe-chip", name).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("today's column should show the date {string}", (day: string) => {
|
||||||
|
cy.contains("th.today .day-date", day).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the next week arrow", () => {
|
||||||
|
cy.get(".week-nav__arrow").last().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the previous week arrow", () => {
|
||||||
|
cy.get(".week-nav__arrow").first().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I open the week calendar", () => {
|
||||||
|
cy.contains("button", "Semaine du").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the calendar popover should be visible", () => {
|
||||||
|
cy.get(".calendar-popover").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the calendar popover should be closed", () => {
|
||||||
|
cy.get(".calendar-popover").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I pick day {int} in the calendar", (day: number) => {
|
||||||
|
cy.get(".calendar-grid__day")
|
||||||
|
.contains(new RegExp(`^${day}$`))
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the nav link {string} should be active", (text: string) => {
|
||||||
|
cy.contains("nav a", text).should("have.class", "active");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the nav link {string} should not be active", (text: string) => {
|
||||||
|
cy.contains("nav a", text).should("not.have.class", "active");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the nav link {string}", (text: string) => {
|
||||||
|
cy.contains("nav a", text).click();
|
||||||
|
});
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: 1,
|
|
||||||
dietId: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Dietary preferences (/parametres/preferences) — hot saving", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
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] });
|
|
||||||
// The page also loads the reference ingredient list + the profile's
|
|
||||||
// disliked-ingredients selection for `DislikedIngredientsField` — added
|
|
||||||
// alongside `getDiets`/`getAllergies` 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.
|
|
||||||
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
|
|
||||||
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads the current regime, and shows allergies/intolerances as two groups", () => {
|
|
||||||
cy.visit("/parametres/preferences");
|
|
||||||
|
|
||||||
cy.get("#diet").should("have.value", "2");
|
|
||||||
cy.contains("legend", "Allergies").should("be.visible");
|
|
||||||
cy.contains("legend", "Intolérances").should("be.visible");
|
|
||||||
cy.contains("label", "Gluten").find("input[type=checkbox]").should("be.checked");
|
|
||||||
cy.contains("label", "Arachides").find("input[type=checkbox]").should("not.be.checked");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has no explicit save button anywhere on the page", () => {
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
35
apps/web/cypress/e2e/preferences.feature
Normal file
35
apps/web/cypress/e2e/preferences.feature
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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: Loads the current regime, and shows allergies/intolerances as two groups
|
||||||
|
When I visit "/parametres/preferences"
|
||||||
|
Then the "diet" field should have the value "2"
|
||||||
|
And I should see the section "Allergies"
|
||||||
|
And I should see the section "Intolérances"
|
||||||
|
And the checkbox "Gluten" should be checked
|
||||||
|
And the checkbox "Arachides" should not be checked
|
||||||
|
|
||||||
|
Scenario: Has no explicit save button anywhere on the page
|
||||||
|
When I visit "/parametres/preferences"
|
||||||
|
Then I should not see "Enregistrer"
|
||||||
|
|
||||||
|
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é ✓"
|
||||||
52
apps/web/cypress/e2e/preferences.steps.ts
Normal file
52
apps/web/cypress/e2e/preferences.steps.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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] });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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é" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
69
apps/web/cypress/e2e/recipe-form.feature
Normal file
69
apps/web/cypress/e2e/recipe-form.feature
Normal file
|
|
@ -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/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é |
|
||||||
159
apps/web/cypress/e2e/recipe-form.steps.ts
Normal file
159
apps/web/cypress/e2e/recipe-form.steps.ts
Normal file
|
|
@ -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/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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,241 +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 vegetarien = { id: 1, key: "vegetarian" };
|
|
||||||
const gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" };
|
|
||||||
const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" };
|
|
||||||
|
|
||||||
const ratatouille = {
|
|
||||||
id: 1,
|
|
||||||
name: "Ratatouille",
|
|
||||||
description: null,
|
|
||||||
picture: null,
|
|
||||||
authorId: 1,
|
|
||||||
visibility: "PERSONAL",
|
|
||||||
allergens: [],
|
|
||||||
diets: [vegetarien],
|
|
||||||
isFavorite: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function interceptAuth() {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Recipe catalog", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
interceptAuth();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to the Favoris tab and lists its recipes", () => {
|
|
||||||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
|
||||||
expect(req.url).to.include("tab=favoris");
|
|
||||||
req.reply({ statusCode: 200, body: [ratatouille] });
|
|
||||||
}).as("listRecipes");
|
|
||||||
|
|
||||||
cy.visit("/recettes");
|
|
||||||
cy.wait("@listRecipes");
|
|
||||||
|
|
||||||
cy.contains("h1", "Recettes").should("be.visible");
|
|
||||||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Favoris");
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
|
||||||
// The favorited row carries the ★ fav-mark.
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille")
|
|
||||||
.find(".recipe-table__fav-mark")
|
|
||||||
.should("exist");
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille")
|
|
||||||
.parents("tr")
|
|
||||||
.find(".diet-badge")
|
|
||||||
.should("contain.text", "Végétarien");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the empty state when a tab has no recipes", () => {
|
|
||||||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
|
||||||
|
|
||||||
cy.visit("/recettes");
|
|
||||||
|
|
||||||
cy.contains("Aucune recette pour le moment.").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error state when the catalog fails to load", () => {
|
|
||||||
cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } });
|
|
||||||
|
|
||||||
cy.visit("/recettes");
|
|
||||||
|
|
||||||
cy.contains(".recipes-page__status--error", "Impossible de charger").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches tabs, re-fetching each one's own recipes", () => {
|
|
||||||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
|
||||||
const tab = new URL(req.url).searchParams.get("tab");
|
|
||||||
const body = tab === "perso" ? [omelette] : [ratatouille];
|
|
||||||
req.reply({ statusCode: 200, body });
|
|
||||||
}).as("listRecipes");
|
|
||||||
|
|
||||||
cy.visit("/recettes");
|
|
||||||
cy.wait("@listRecipes");
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
|
||||||
|
|
||||||
// Not asserting the specific request URL here — React StrictMode (see
|
|
||||||
// main.tsx) double-invokes mount/update effects in dev, so this can
|
|
||||||
// legitimately fire twice; the rendered result converges either way
|
|
||||||
// (same reasoning as planning-page.cy.ts's week-navigation tests).
|
|
||||||
cy.contains(".recipe-tabs__tab", "Perso").click();
|
|
||||||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
|
|
||||||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
|
||||||
|
|
||||||
// The disabled "Sources (bientôt)" placeholder never becomes active.
|
|
||||||
cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("searches within the active tab, debounced", () => {
|
|
||||||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
|
||||||
const search = new URL(req.url).searchParams.get("search");
|
|
||||||
req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] });
|
|
||||||
}).as("listRecipes");
|
|
||||||
|
|
||||||
cy.visit("/recettes");
|
|
||||||
cy.wait("@listRecipes");
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
|
||||||
|
|
||||||
// Same "assert the rendered result, not the request count/URL" reasoning
|
|
||||||
// as the tab-switch test above — the 300ms debounce plus StrictMode's
|
|
||||||
// double-invoked effects make the exact number/order of requests an
|
|
||||||
// implementation detail, not something worth pinning down here.
|
|
||||||
cy.get(".recipes-page__search").type("Omel");
|
|
||||||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens a recipe's detail alongside the table when its row is selected", () => {
|
|
||||||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
|
|
||||||
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe");
|
|
||||||
|
|
||||||
cy.visit("/recettes");
|
|
||||||
cy.contains(".recipe-table__name", "Omelette").click();
|
|
||||||
cy.wait("@getRecipe");
|
|
||||||
|
|
||||||
cy.url().should("include", "/recettes/2");
|
|
||||||
// The table stays mounted (master-detail, not a page navigation) —
|
|
||||||
// both rows are still visible next to the detail panel.
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
|
||||||
cy.get("tr.selected .recipe-table__name").should("contain.text", "Omelette");
|
|
||||||
|
|
||||||
cy.get(".recipe-detail-panel").within(() => {
|
|
||||||
cy.contains("h2", "Omelette").should("be.visible");
|
|
||||||
cy.contains("Une omelette toute simple.").should("be.visible");
|
|
||||||
cy.contains("Battre les œufs.").should("be.visible");
|
|
||||||
cy.contains("Cuire à la poêle.").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a not-found message for a selected id the API rejects", () => {
|
|
||||||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
|
||||||
cy.intercept("GET", "**/recipes/999", {
|
|
||||||
statusCode: 404,
|
|
||||||
// ErrorCode.RECIPE_NOT_FOUND (packages/shared/src/errors/error-codes.ts)
|
|
||||||
// — RecipeDetailPanel only renders the "not found" message for this
|
|
||||||
// exact code, anything else falls into its generic error state.
|
|
||||||
body: { code: 4045, message: "not found" },
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.visit("/recettes/999");
|
|
||||||
|
|
||||||
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: [] });
|
|
||||||
|
|
||||||
cy.visit("/recettes");
|
|
||||||
|
|
||||||
cy.contains(".recipes-page__new-button", "Nouvelle recette").should(
|
|
||||||
"have.attr",
|
|
||||||
"href",
|
|
||||||
"/recettes/nouvelle",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
98
apps/web/cypress/e2e/recipes.feature
Normal file
98
apps/web/cypress/e2e/recipes.feature
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
Feature: Recipe catalog
|
||||||
|
As a signed-in user
|
||||||
|
I want to browse, search, and manage my recipes in a master-detail view
|
||||||
|
So that I can find a recipe and see/edit its details without leaving the list
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And my household id is 1
|
||||||
|
And the disliked ingredients list is empty
|
||||||
|
|
||||||
|
Scenario: Defaults to the Favoris tab and lists its recipes
|
||||||
|
Given the recipe catalog defaults to the favorites tab with "Ratatouille"
|
||||||
|
When I visit "/recettes"
|
||||||
|
Then the recipe list request should have been made
|
||||||
|
And I should see the heading "Recettes"
|
||||||
|
And the active tab should be "Favoris"
|
||||||
|
And the recipe "Ratatouille" should be visible in the table
|
||||||
|
And the recipe "Ratatouille" should be marked as favorite
|
||||||
|
And the recipe "Ratatouille" should show the diet badge "Végétarien"
|
||||||
|
|
||||||
|
Scenario: Shows the empty state when a tab has no recipes
|
||||||
|
Given the recipe catalog is empty
|
||||||
|
When I visit "/recettes"
|
||||||
|
Then I should see "Aucune recette pour le moment."
|
||||||
|
|
||||||
|
Scenario: Shows an error state when the catalog fails to load
|
||||||
|
Given the recipe catalog fails to load
|
||||||
|
When I visit "/recettes"
|
||||||
|
Then I should see "Impossible de charger"
|
||||||
|
|
||||||
|
Scenario: Switches tabs, re-fetching each one's own recipes
|
||||||
|
Given the recipe catalog switches between tabs
|
||||||
|
When I visit "/recettes"
|
||||||
|
Then the recipe list request should have been made
|
||||||
|
And the recipe "Ratatouille" should be visible in the table
|
||||||
|
When I click the tab "Perso"
|
||||||
|
Then the active tab should be "Perso"
|
||||||
|
And the recipe "Omelette" should be visible in the table
|
||||||
|
And the recipe "Ratatouille" should not be visible in the table
|
||||||
|
And the tab "Sources (bientôt)" should be disabled
|
||||||
|
|
||||||
|
Scenario: Searches within the active tab, debounced
|
||||||
|
Given the recipe catalog supports searching
|
||||||
|
When I visit "/recettes"
|
||||||
|
Then the recipe list request should have been made
|
||||||
|
And the recipe "Ratatouille" should be visible in the table
|
||||||
|
When I search for "Omel"
|
||||||
|
Then the recipe "Omelette" should be visible in the table
|
||||||
|
And the recipe "Ratatouille" should not be visible in the table
|
||||||
|
|
||||||
|
Scenario: Opens a recipe's detail alongside the table when its row is selected
|
||||||
|
Given the recipe catalog contains "Ratatouille" and "Omelette"
|
||||||
|
And recipe 2's detail is available
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the recipe "Omelette" in the table
|
||||||
|
Then the recipe detail request should have been made
|
||||||
|
And the URL should include "/recettes/2"
|
||||||
|
And the recipe "Ratatouille" should be visible in the table
|
||||||
|
And the selected row should be "Omelette"
|
||||||
|
And the recipe detail panel heading should be "Omelette"
|
||||||
|
And the recipe detail panel should show "Une omelette toute simple."
|
||||||
|
And the recipe detail panel should show "Battre les œufs."
|
||||||
|
And the recipe detail panel should show "Cuire à la poêle."
|
||||||
|
|
||||||
|
Scenario: Shows a not-found message for a selected id the API rejects
|
||||||
|
Given the recipe catalog is empty
|
||||||
|
And recipe 999 does not exist
|
||||||
|
When I visit "/recettes/999"
|
||||||
|
Then the recipe detail panel should say the recipe doesn't exist
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
Scenario: Links the new-recipe button to the recipe form
|
||||||
|
Given the recipe catalog is empty
|
||||||
|
When I visit "/recettes"
|
||||||
|
Then the new-recipe button should link to "/recettes/nouvelle"
|
||||||
215
apps/web/cypress/e2e/recipes.steps.ts
Normal file
215
apps/web/cypress/e2e/recipes.steps.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
const vegetarien = { id: 1, key: "vegetarian" };
|
||||||
|
const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" };
|
||||||
|
|
||||||
|
const ratatouille = {
|
||||||
|
id: 1,
|
||||||
|
name: "Ratatouille",
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
authorId: 1,
|
||||||
|
visibility: "PERSONAL",
|
||||||
|
allergens: [],
|
||||||
|
diets: [vegetarien],
|
||||||
|
isFavorite: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 defaults to the favorites tab with {string}", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||||||
|
expect(req.url).to.include("tab=favoris");
|
||||||
|
req.reply({ statusCode: 200, body: [ratatouille] });
|
||||||
|
}).as("listRecipes");
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the recipe catalog is empty", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the recipe catalog fails to load", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the recipe catalog switches between tabs", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||||||
|
const tab = new URL(req.url).searchParams.get("tab");
|
||||||
|
const body = tab === "perso" ? [omelette] : [ratatouille];
|
||||||
|
req.reply({ statusCode: 200, body });
|
||||||
|
}).as("listRecipes");
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the recipe catalog supports searching", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||||||
|
const search = new URL(req.url).searchParams.get("search");
|
||||||
|
req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] });
|
||||||
|
}).as("listRecipes");
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the recipe catalog contains {string} and {string}", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
|
||||||
|
});
|
||||||
|
|
||||||
|
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("recipe 999 does not exist", () => {
|
||||||
|
cy.intercept("GET", "**/recipes/999", {
|
||||||
|
statusCode: 404,
|
||||||
|
// ErrorCode.RECIPE_NOT_FOUND (packages/shared/src/errors/error-codes.ts)
|
||||||
|
// — RecipeDetailPanel only renders the "not found" message for this
|
||||||
|
// exact code, anything else falls into its generic error state.
|
||||||
|
body: { code: 4045, message: "not found" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 list request should have been made", () => {
|
||||||
|
cy.wait("@listRecipes");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the recipe detail request should have been made", () => {
|
||||||
|
cy.wait("@getRecipe");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the active tab should be {string}", (tab: string) => {
|
||||||
|
cy.get(".recipe-tabs__tab.active").should("contain.text", tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the recipe {string} should be visible in the table", (name: string) => {
|
||||||
|
cy.contains(".recipe-table__name", name).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {string} should show the diet badge {string}", (name: string, badge: string) => {
|
||||||
|
cy.contains(".recipe-table__name", name)
|
||||||
|
.parents("tr")
|
||||||
|
.find(".diet-badge")
|
||||||
|
.should("contain.text", badge);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the tab {string} should be disabled", (text: string) => {
|
||||||
|
cy.contains(".recipe-tabs__tab", text).should("be.disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the tab {string}", (text: string) => {
|
||||||
|
cy.contains(".recipe-tabs__tab", text).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I search for {string}", (text: string) => {
|
||||||
|
cy.get(".recipes-page__search").type(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the recipe {string} in the table", (text: string) => {
|
||||||
|
cy.contains(".recipe-table__name", text).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the selected row should be {string}", (text: string) => {
|
||||||
|
cy.get("tr.selected .recipe-table__name").should("contain.text", text);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the recipe detail panel heading should be {string}", (text: string) => {
|
||||||
|
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the recipe detail panel should show {string}", (text: string) => {
|
||||||
|
cy.get(".recipe-detail-panel").contains(text).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the recipe detail panel should say the recipe doesn't exist", () => {
|
||||||
|
cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").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\/?$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the new-recipe button should link to {string}", (href: string) => {
|
||||||
|
cy.contains(".recipes-page__new-button", "Nouvelle recette").should("have.attr", "href", href);
|
||||||
|
});
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: null,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Sidebar — settings menu and account menu", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
// Not "**/planning*" — that glob also matches the Vite dev request for
|
|
||||||
// planning-page.scss (see planning-page.cy.ts for the same gotcha).
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("no longer lists Foyer in the main nav", () => {
|
|
||||||
cy.visit("/");
|
|
||||||
cy.get(".app-sidebar__nav a").should("not.contain", "Foyer");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reveals the four settings pages behind the Paramètres toggle", () => {
|
|
||||||
cy.visit("/");
|
|
||||||
|
|
||||||
cy.contains("a", "Compte").should("not.exist");
|
|
||||||
cy.contains("button", "Paramètres").click();
|
|
||||||
|
|
||||||
cy.contains("a", "Compte").should("have.attr", "href", "/parametres/compte");
|
|
||||||
cy.contains("a", "Préférences alimentaires").should(
|
|
||||||
"have.attr",
|
|
||||||
"href",
|
|
||||||
"/parametres/preferences",
|
|
||||||
);
|
|
||||||
cy.contains("a", "Foyer").should("have.attr", "href", "/parametres/foyer");
|
|
||||||
cy.contains("a", "Préférences utilisateur").should(
|
|
||||||
"have.attr",
|
|
||||||
"href",
|
|
||||||
"/parametres/preferences-utilisateur",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens the account menu from the greeting and links to Mon compte", () => {
|
|
||||||
cy.visit("/");
|
|
||||||
|
|
||||||
cy.contains("a", "Mon compte").should("not.exist");
|
|
||||||
cy.contains("button", "Bonjour Alice").click();
|
|
||||||
cy.contains("a", "Mon compte").click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/parametres/compte");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redirects the old /foyer path to /parametres/foyer", () => {
|
|
||||||
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
|
|
||||||
cy.visit("/foyer");
|
|
||||||
cy.url().should("include", "/parametres/foyer");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses to an icon-only rail and back, persisting the choice across reloads", () => {
|
|
||||||
cy.visit("/");
|
|
||||||
|
|
||||||
cy.get(".app-sidebar").should("not.have.class", "collapsed");
|
|
||||||
cy.contains("nav a", "Planning").should("be.visible");
|
|
||||||
|
|
||||||
cy.get(".app-sidebar__collapse-toggle").click();
|
|
||||||
cy.get(".app-sidebar").should("have.class", "collapsed");
|
|
||||||
// The label text hides (not removed from the DOM — still there for the
|
|
||||||
// `title` tooltip/accessibility), while the link itself stays visible,
|
|
||||||
// icon-only.
|
|
||||||
cy.contains("span.label", "Planning").should("exist").and("not.be.visible");
|
|
||||||
cy.get("nav a[title='Planning']").should("be.visible");
|
|
||||||
|
|
||||||
cy.reload();
|
|
||||||
cy.get(".app-sidebar").should("have.class", "collapsed");
|
|
||||||
|
|
||||||
cy.get(".app-sidebar__collapse-toggle").click();
|
|
||||||
cy.get(".app-sidebar").should("not.have.class", "collapsed");
|
|
||||||
cy.contains("nav a", "Planning").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
47
apps/web/cypress/e2e/sidebar.feature
Normal file
47
apps/web/cypress/e2e/sidebar.feature
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
Feature: Sidebar — settings menu and account menu
|
||||||
|
As a signed-in user
|
||||||
|
I want to reach the settings pages and collapse the sidebar
|
||||||
|
So that I can navigate the app comfortably regardless of screen space
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And the planning request returns nothing
|
||||||
|
|
||||||
|
Scenario: No longer lists Foyer in the main nav
|
||||||
|
When I visit "/"
|
||||||
|
Then the sidebar's main nav should not mention "Foyer"
|
||||||
|
|
||||||
|
Scenario: Reveals the four settings pages behind the Paramètres toggle
|
||||||
|
When I visit "/"
|
||||||
|
Then I should not see "Compte"
|
||||||
|
When I click the button "Paramètres"
|
||||||
|
Then the link "Compte" should point to "/parametres/compte"
|
||||||
|
And the link "Préférences alimentaires" should point to "/parametres/preferences"
|
||||||
|
And the link "Foyer" should point to "/parametres/foyer"
|
||||||
|
And the link "Préférences utilisateur" should point to "/parametres/preferences-utilisateur"
|
||||||
|
|
||||||
|
Scenario: Opens the account menu from the greeting and links to Mon compte
|
||||||
|
When I visit "/"
|
||||||
|
Then I should not see "Mon compte"
|
||||||
|
When I open the account menu
|
||||||
|
And I click the link "Mon compte"
|
||||||
|
Then the URL should include "/parametres/compte"
|
||||||
|
|
||||||
|
Scenario: Redirects the old /foyer path to /parametres/foyer
|
||||||
|
Given the household request returns no household
|
||||||
|
When I visit "/foyer"
|
||||||
|
Then the URL should include "/parametres/foyer"
|
||||||
|
|
||||||
|
Scenario: Collapses to an icon-only rail and back, persisting the choice across reloads
|
||||||
|
When I visit "/"
|
||||||
|
Then the sidebar should not be collapsed
|
||||||
|
And the nav link "Planning" should be visible
|
||||||
|
When I toggle the sidebar collapse
|
||||||
|
Then the sidebar should be collapsed
|
||||||
|
And the nav link "Planning"'s label should be hidden
|
||||||
|
And the nav link titled "Planning" should be visible
|
||||||
|
When I reload the page
|
||||||
|
Then the sidebar should be collapsed
|
||||||
|
When I toggle the sidebar collapse
|
||||||
|
Then the sidebar should not be collapsed
|
||||||
|
And the nav link "Planning" should be visible
|
||||||
36
apps/web/cypress/e2e/sidebar.steps.ts
Normal file
36
apps/web/cypress/e2e/sidebar.steps.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
Then("the sidebar's main nav should not mention {string}", (text: string) => {
|
||||||
|
cy.get(".app-sidebar__nav a").should("not.contain", text);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the sidebar should be collapsed", () => {
|
||||||
|
cy.get(".app-sidebar").should("have.class", "collapsed");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the sidebar should not be collapsed", () => {
|
||||||
|
cy.get(".app-sidebar").should("not.have.class", "collapsed");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I toggle the sidebar collapse", () => {
|
||||||
|
cy.get(".app-sidebar__collapse-toggle").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the nav link {string} should be visible", (text: string) => {
|
||||||
|
cy.contains("nav a", text).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The label text stays in the DOM (still there for the `title`
|
||||||
|
// tooltip/accessibility) — it's hidden via CSS, not removed, hence
|
||||||
|
// `.should("exist").and("not.be.visible")` rather than `.should("not.exist")`.
|
||||||
|
Then("the nav link {string}'s label should be hidden", (text: string) => {
|
||||||
|
cy.contains("span.label", text).should("exist").and("not.be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the nav link titled {string} should be visible", (title: string) => {
|
||||||
|
cy.get(`nav a[title="${title}"]`).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I reload the page", () => {
|
||||||
|
cy.reload();
|
||||||
|
});
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
describe("smoke test", () => {
|
|
||||||
it("redirects an unauthenticated visitor to the login page", () => {
|
|
||||||
cy.intercept("GET", "**/auth/me", {
|
|
||||||
statusCode: 401,
|
|
||||||
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "Not authenticated" },
|
|
||||||
});
|
|
||||||
cy.visit("/");
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
cy.contains("h1", "Se connecter").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
10
apps/web/cypress/e2e/smoke.feature
Normal file
10
apps/web/cypress/e2e/smoke.feature
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
Feature: Unauthenticated access
|
||||||
|
As a visitor without a session
|
||||||
|
I want to be redirected to the login page
|
||||||
|
So that I can sign in before using the app
|
||||||
|
|
||||||
|
Scenario: Visiting the app without a session redirects to login
|
||||||
|
Given I am not signed in
|
||||||
|
When I visit "/"
|
||||||
|
Then the URL should include "/login"
|
||||||
|
And I should see the heading "Se connecter"
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale.
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: null,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("User preferences (/parametres/preferences-utilisateur)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows SYSTEM selected by default", () => {
|
|
||||||
cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "SYSTEM" } });
|
|
||||||
|
|
||||||
cy.visit("/parametres/preferences-utilisateur");
|
|
||||||
|
|
||||||
cy.contains("label", "Système").find("input[type=radio]").should("be.checked");
|
|
||||||
cy.contains("label", "Clair").find("input[type=radio]").should("not.be.checked");
|
|
||||||
cy.contains("label", "Sombre").find("input[type=radio]").should("not.be.checked");
|
|
||||||
// SYSTEM never sets an override — the OS/browser preference decides.
|
|
||||||
cy.get("html").should("not.have.attr", "data-theme");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the previously saved theme selected, and applies it to the document", () => {
|
|
||||||
cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "DARK" } });
|
|
||||||
|
|
||||||
cy.visit("/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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
31
apps/web/cypress/e2e/user-preferences.feature
Normal file
31
apps/web/cypress/e2e/user-preferences.feature
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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: Shows SYSTEM selected by default
|
||||||
|
Given my saved theme preference is "SYSTEM"
|
||||||
|
When I visit "/parametres/preferences-utilisateur"
|
||||||
|
Then the radio "Système" should be checked
|
||||||
|
And the radio "Clair" should not be checked
|
||||||
|
And the radio "Sombre" should not be checked
|
||||||
|
And the page should have no theme override
|
||||||
|
|
||||||
|
Scenario: Shows the previously saved theme selected, and applies it to the document
|
||||||
|
Given my saved theme preference is "DARK"
|
||||||
|
When I visit "/parametres/preferences-utilisateur"
|
||||||
|
Then the radio "Sombre" should be checked
|
||||||
|
And the page theme should be "dark"
|
||||||
|
|
||||||
|
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"
|
||||||
15
apps/web/cypress/e2e/user-preferences.steps.ts
Normal file
15
apps/web/cypress/e2e/user-preferences.steps.ts
Normal file
|
|
@ -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 });
|
||||||
|
});
|
||||||
33
apps/web/cypress/support/profile.ts
Normal file
33
apps/web/cypress/support/profile.ts
Normal file
|
|
@ -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> = {}): 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;
|
||||||
|
}
|
||||||
184
apps/web/cypress/support/step_definitions/common.steps.ts
Normal file
184
apps/web/cypress/support/step_definitions/common.steps.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { Before, 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 `<name>.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.
|
||||||
|
|
||||||
|
Before(() => {
|
||||||
|
resetProfile();
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("I am not signed in", () => {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("I am signed in as {string} {string}", (firstName: string, lastName: string) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,8 @@
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@badeball/cypress-cucumber-preprocessor": "^26.0.0",
|
||||||
|
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
|
|
||||||
1746
pnpm-lock.yaml
1746
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue