diff --git a/apps/web/cypress/e2e/account.cy.ts b/apps/web/cypress/e2e/account.cy.ts index 65a79da..832d19e 100644 --- a/apps/web/cypress/e2e/account.cy.ts +++ b/apps/web/cypress/e2e/account.cy.ts @@ -1,6 +1,9 @@ -import { ErrorCode } from "@batch-cooking/shared"; - -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The account-deletion journey (wrong password, success, cancel) moved to +// account.feature — this file now only covers what's left: passive display. const authenticatedProfile = { id: 1, @@ -24,45 +27,4 @@ describe("Account settings (/parametres/compte)", () => { cy.contains("Martin").should("be.visible"); cy.contains("alice@example.com").should("be.visible"); }); - - it("shows an error and keeps the session when the password is wrong", () => { - cy.intercept("DELETE", "**/auth/me", { - statusCode: 401, - body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid password" }, - }).as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.get("#deleteAccountPassword").type("wrong-password"); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteAccount"); - cy.contains("Email ou mot de passe incorrect").should("be.visible"); - cy.url().should("include", "/parametres/compte"); - }); - - it("deletes the account and returns to the login page", () => { - cy.intercept("DELETE", "**/auth/me", { statusCode: 204 }).as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.get("#deleteAccountPassword").type("correct-horse-battery-staple"); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteAccount") - .its("request.body") - .should("deep.equal", { password: "correct-horse-battery-staple" }); - cy.url().should("include", "/login"); - }); - - it("cancels the deletion without calling the API", () => { - cy.intercept("DELETE", "**/auth/me").as("deleteAccount"); - - cy.visit("/parametres/compte"); - cy.contains("button", "Supprimer mon compte").click(); - cy.contains("button", "Annuler").click(); - - cy.contains("button", "Confirmer la suppression").should("not.exist"); - cy.get("@deleteAccount.all").should("have.length", 0); - }); }); diff --git a/apps/web/cypress/e2e/account.feature b/apps/web/cypress/e2e/account.feature new file mode 100644 index 0000000..7cedfa4 --- /dev/null +++ b/apps/web/cypress/e2e/account.feature @@ -0,0 +1,34 @@ +Feature: Account deletion + As a signed-in user + I want to permanently delete my account + So that I stay in control of my data + + Background: + Given I am signed in as "Alice" "Martin" + + Scenario: Shows an error and keeps the session when the password is wrong + Given the account deletion request will fail because the credentials are invalid + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I fill in the "deleteAccountPassword" field with "wrong-password" + And I click the button "Confirmer la suppression" + Then the account deletion request should have been made + And I should see "Email ou mot de passe incorrect" + And the URL should include "/parametres/compte" + + Scenario: Deletes the account and returns to the login page + Given the account deletion request will succeed + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I fill in the "deleteAccountPassword" field with "correct-horse-battery-staple" + And I click the button "Confirmer la suppression" + Then the account deletion request should have been made with password "correct-horse-battery-staple" + And the URL should include "/login" + + Scenario: Cancels the deletion without calling the API + Given the account deletion request is being watched + When I visit "/parametres/compte" + And I click the button "Supprimer mon compte" + And I click the button "Annuler" + Then I should not see "Confirmer la suppression" + And the account deletion request should not have been made diff --git a/apps/web/cypress/e2e/account.ts b/apps/web/cypress/e2e/account.ts new file mode 100644 index 0000000..a3f0163 --- /dev/null +++ b/apps/web/cypress/e2e/account.ts @@ -0,0 +1,32 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; +import { ErrorCode } from "@batch-cooking/shared"; + +Given("the account deletion request will fail because the credentials are invalid", () => { + cy.intercept("DELETE", "**/auth/me", { + statusCode: 401, + body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid password" }, + }).as("deleteAccount"); +}); + +Given("the account deletion request will succeed", () => { + cy.intercept("DELETE", "**/auth/me", { statusCode: 204 }).as("deleteAccount"); +}); + +Given("the account deletion request is being watched", () => { + cy.intercept("DELETE", "**/auth/me").as("deleteAccount"); +}); + +Then("the account deletion request should have been made", () => { + cy.wait("@deleteAccount"); +}); + +Then( + "the account deletion request should have been made with password {string}", + (password: string) => { + cy.wait("@deleteAccount").its("request.body").should("deep.equal", { password }); + }, +); + +Then("the account deletion request should not have been made", () => { + cy.get("@deleteAccount.all").should("have.length", 0); +}); diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts deleted file mode 100644 index e0baebd..0000000 --- a/apps/web/cypress/e2e/auth.cy.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ErrorCode } from "@batch-cooking/shared"; - -// Mocks the API via cy.intercept — this job doesn't run a live backend (see -// .github/workflows/ci.yml), and it keeps these specs focused on frontend -// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber -// suites against a real database. - -describe("Signup", () => { - it("creates a profile and starts the onboarding wizard (regime/household/allergens)", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - // The onboarding wizard's first step (see onboarding.cy.ts for the full - // walkthrough) is the regime step, which fetches the reference list. - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("POST", "**/auth/signup", { - statusCode: 201, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: null, - dietId: null, - }, - }).as("signup"); - - cy.visit("/signup"); - cy.get("#firstName").type("Alice"); - cy.get("#lastName").type("Martin"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Créer mon profil").click(); - - cy.wait("@signup"); - // Not the home page directly — signup hands off to the onboarding - // wizard first (RedirectIfAuthenticated no longer applies here, it's a - // RequireAuth-gated route of its own, see App.tsx). - cy.url().should("include", "/onboarding/regime"); - cy.contains("Étape 1 sur 3").should("be.visible"); - }); - - it("shows a client-side validation error without calling the API", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup").as("signup"); - - cy.visit("/signup"); - cy.get("#firstName").type("A"); - cy.get("#lastName").type("B"); - cy.get("#email").type("a@example.com"); - cy.get("#password").type("short"); - cy.contains("button", "Créer mon profil").click(); - - cy.contains("8 caractères minimum").should("be.visible"); - cy.get("@signup.all").should("have.length", 0); - }); - - it("shows the API's error when the email is already taken", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup", { - statusCode: 409, - body: { code: ErrorCode.EMAIL_ALREADY_IN_USE, message: "Email already in use" }, - }).as("signup"); - - cy.visit("/signup"); - cy.get("#firstName").type("Alice"); - cy.get("#lastName").type("Martin"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Créer mon profil").click(); - - cy.wait("@signup"); - cy.contains("Cet email est déjà utilisé").should("be.visible"); - }); -}); - -describe("Login", () => { - it("logs in and lands on the home page", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - cy.intercept("POST", "**/auth/login", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }).as("login"); - - cy.visit("/login"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Se connecter").click(); - - cy.wait("@login"); - cy.contains("Bonjour Alice").should("be.visible"); - }); - - it("shows an error on invalid credentials", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/login", { - statusCode: 401, - body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid email or password" }, - }).as("login"); - - cy.visit("/login"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("wrong-password"); - cy.contains("button", "Se connecter").click(); - - cy.wait("@login"); - cy.contains("Email ou mot de passe incorrect").should("be.visible"); - }); -}); - -describe("Already authenticated", () => { - it("redirects away from /login to the home page", () => { - cy.intercept("GET", "**/auth/me", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - - cy.visit("/login"); - cy.url().should("not.include", "/login"); - cy.contains("Bonjour Alice").should("be.visible"); - }); - - it("logs out and returns to the login page", () => { - cy.intercept("GET", "**/auth/me", { - statusCode: 200, - body: { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, - }, - }); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); - - cy.visit("/"); - // "Se déconnecter" lives inside the account menu, opened by clicking - // the greeting button — see AppLayout.tsx's AccountMenu. - cy.contains("button", "Bonjour Alice").click(); - cy.contains("button", "Se déconnecter").click(); - - cy.wait("@logout"); - cy.url().should("include", "/login"); - }); -}); diff --git a/apps/web/cypress/e2e/auth.feature b/apps/web/cypress/e2e/auth.feature new file mode 100644 index 0000000..dc3763a --- /dev/null +++ b/apps/web/cypress/e2e/auth.feature @@ -0,0 +1,63 @@ +Feature: Signup and login + As a visitor + I want to create a profile or log into an existing one + So that I can access my household's batch-cooking planning + + Background: + Given I am not signed in + + Scenario: Signing up creates a profile and starts the onboarding wizard + Given the signup request will succeed + And the diets reference list is empty + When I sign up with: + | firstName | Alice | + | lastName | Martin | + | email | alice@example.com | + | password | correct-horse-battery-staple | + Then the signup request should have been made + And the URL should include "/onboarding/regime" + And I should see "Étape 1 sur 3" + + Scenario: Signing up shows a client-side validation error without calling the API + Given the signup request is being watched + When I sign up with: + | firstName | A | + | lastName | B | + | email | a@example.com | + | password | short | + Then I should see "8 caractères minimum" + And the signup request should not have been made + + Scenario: Signing up shows the API's error when the email is already taken + Given the signup request will fail because the email is already used + When I sign up with: + | firstName | Alice | + | lastName | Martin | + | email | alice@example.com | + | password | correct-horse-battery-staple | + Then the signup request should have been made + And I should see "Cet email est déjà utilisé" + + Scenario: Logging in lands on the home page + Given the login request will succeed + And the planning request returns nothing + When I log in with email "alice@example.com" and password "correct-horse-battery-staple" + Then the login request should have been made + And I should see "Bonjour Alice" + + Scenario: Logging in shows an error on invalid credentials + Given the login request will fail because the credentials are invalid + When I log in with email "alice@example.com" and password "wrong-password" + Then the login request should have been made + And I should see "Email ou mot de passe incorrect" + + Scenario: Logging out returns to the login page + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the planning request returns nothing + And the logout request will succeed + When I visit "/" + And I open the account menu + And I click the button "Se déconnecter" + Then the logout request should have been made + And the URL should include "/login" diff --git a/apps/web/cypress/e2e/auth.ts b/apps/web/cypress/e2e/auth.ts new file mode 100644 index 0000000..9aaacc4 --- /dev/null +++ b/apps/web/cypress/e2e/auth.ts @@ -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"); +}); diff --git a/apps/web/cypress/e2e/household-settings.cy.ts b/apps/web/cypress/e2e/household-settings.cy.ts index eb4acd1..e8c5032 100644 --- a/apps/web/cypress/e2e/household-settings.cy.ts +++ b/apps/web/cypress/e2e/household-settings.cy.ts @@ -1,4 +1,10 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The household-management journey (create, join, rename, remove a member, +// delete, leave) moved to household-settings.feature — this file now only +// covers what's left: passive display of each screen's initial state. const adminProfile = { id: 1, @@ -33,51 +39,6 @@ describe("Household settings (/parametres/foyer) — no household yet", () => { cy.contains("Créer un foyer").should("be.visible"); cy.contains("Rejoindre un foyer").should("be.visible"); }); - - it("creates a household", () => { - const createdHouse = { - id: 1, - name: "Chez Alice", - adminId: 1, - inviteCode: "ABCD2345", - members: [{ id: 1, firstName: "Alice", lastName: "Martin" }], - }; - // The page reloads `GET /house/current` right after creating succeeds — - // see the "deletes the household" test above for the same pattern. - let created = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: created ? createdHouse : null }); - }); - cy.intercept("POST", "**/house", (req) => { - created = true; - req.reply({ statusCode: 201, body: createdHouse }); - }).as("createHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#houseName").type("Chez Alice"); - cy.contains("button", "Créer").click(); - - cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); - cy.contains("ABCD2345").should("be.visible"); - }); - - it("joins a household by invite code", () => { - let joined = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: joined ? houseWithTwoMembers : null }); - }); - cy.intercept("POST", "**/house/join", (req) => { - joined = true; - req.reply({ statusCode: 200, body: houseWithTwoMembers }); - }).as("joinHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#inviteCode").type("abcd2345"); - cy.contains("button", "Rejoindre").click(); - - cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" }); - cy.contains("Bob Dupont").should("be.visible"); - }); }); describe("Household settings (/parametres/foyer) — as the admin", () => { @@ -94,68 +55,6 @@ describe("Household settings (/parametres/foyer) — as the admin", () => { cy.contains("Bob Dupont").should("be.visible"); cy.contains("Alice Martin").parent().contains("Admin"); }); - - it("autosaves the household name", () => { - cy.intercept("PATCH", "**/house/current", { - statusCode: 200, - body: { ...houseWithTwoMembers, name: "Chez les Martin" }, - }).as("renameHouse"); - - cy.visit("/parametres/foyer"); - cy.get("#houseName").clear(); - cy.get("#houseName").type("Chez les Martin"); - - cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" }); - cy.contains("Enregistré ✓").should("be.visible"); - }); - - it("removes a member", () => { - // Same reasoning as the "deletes the household" test below — the page - // reloads `GET /house/current` right after the removal succeeds. - let memberRemoved = false; - cy.intercept("GET", "**/house/current", (req) => { - const body = memberRemoved - ? { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] } - : houseWithTwoMembers; - req.reply({ statusCode: 200, body }); - }); - cy.intercept("DELETE", "**/house/members/2", (req) => { - memberRemoved = true; - req.reply({ - statusCode: 200, - body: { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] }, - }); - }).as("removeMember"); - - cy.visit("/parametres/foyer"); - cy.contains("li", "Bob Dupont").contains("button", "Retirer").click(); - - cy.wait("@removeMember"); - cy.contains("Bob Dupont").should("not.exist"); - }); - - it("deletes the household after confirming", () => { - // The page reloads `GET /house/current` right after the delete - // succeeds — this intercept needs to answer differently before/after - // that DELETE, hence the shared mutable flag rather than two static - // `cy.intercept` calls (the later one would just win for every request, - // including the initial page load). - let houseDeleted = false; - cy.intercept("GET", "**/house/current", (req) => { - req.reply({ statusCode: 200, body: houseDeleted ? null : houseWithTwoMembers }); - }); - cy.intercept("DELETE", "**/house/current", (req) => { - houseDeleted = true; - req.reply({ statusCode: 204 }); - }).as("deleteHouse"); - - cy.visit("/parametres/foyer"); - cy.contains("button", "Supprimer le foyer").click(); - cy.contains("button", "Confirmer la suppression").click(); - - cy.wait("@deleteHouse"); - cy.contains("Créer un foyer").should("be.visible"); - }); }); describe("Household settings (/parametres/foyer) — as a non-admin member", () => { @@ -173,13 +72,4 @@ describe("Household settings (/parametres/foyer) — as a non-admin member", () cy.contains("button", "Quitter le foyer").should("be.visible"); cy.contains("button", "Supprimer le foyer").should("not.exist"); }); - - it("leaves the household", () => { - cy.intercept("POST", "**/house/leave", { statusCode: 204 }).as("leaveHouse"); - - cy.visit("/parametres/foyer"); - cy.contains("button", "Quitter le foyer").click(); - - cy.wait("@leaveHouse"); - }); }); diff --git a/apps/web/cypress/e2e/household-settings.feature b/apps/web/cypress/e2e/household-settings.feature new file mode 100644 index 0000000..078cdc7 --- /dev/null +++ b/apps/web/cypress/e2e/household-settings.feature @@ -0,0 +1,64 @@ +Feature: Household settings + As a signed-in user + I want to create, join, manage, or leave a household + So that I can share a batch-cooking plan with the people I cook with + + Scenario: Creates a household + Given I am signed in as "Alice" "Martin" + And creating a household will succeed + When I visit "/parametres/foyer" + And I fill in the "houseName" field with "Chez Alice" + And I click the button "Créer" + Then the household creation request should have been made with name "Chez Alice" + And I should see "ABCD2345" + + Scenario: Joins a household by invite code + Given I am signed in as "Alice" "Martin" + And joining a household will succeed + When I visit "/parametres/foyer" + And I fill in the "inviteCode" field with "abcd2345" + And I click the button "Rejoindre" + Then the household join request should have been made with invite code "ABCD2345" + And I should see "Bob Dupont" + + Scenario: Autosaves the household name + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And renaming the household will succeed + When I visit "/parametres/foyer" + And I clear the "houseName" field + And I fill in the "houseName" field with "Chez les Martin" + Then the household rename request should have been made with name "Chez les Martin" + And I should see "Enregistré ✓" + + Scenario: Removes a member + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And removing Bob from the household will succeed + When I visit "/parametres/foyer" + And I click "Retirer" for the member "Bob Dupont" + Then the member removal request should have been made + And I should not see "Bob Dupont" + + Scenario: Deletes the household after confirming + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the household request returns the two-member household + And deleting the household will succeed + When I visit "/parametres/foyer" + And I click the button "Supprimer le foyer" + And I click the button "Confirmer la suppression" + Then the household deletion request should have been made + And I should see "Créer un foyer" + + Scenario: Leaves the household + Given I am signed in as "Bob" "Dupont" + And my user id is 2 + And my household id is 1 + And the household request returns the two-member household + And leaving the household will succeed + When I visit "/parametres/foyer" + And I click the button "Quitter le foyer" + Then the household leave request should have been made diff --git a/apps/web/cypress/e2e/household-settings.ts b/apps/web/cypress/e2e/household-settings.ts new file mode 100644 index 0000000..542f192 --- /dev/null +++ b/apps/web/cypress/e2e/household-settings.ts @@ -0,0 +1,116 @@ +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("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"); +}); diff --git a/apps/web/cypress/e2e/login-smoke.feature b/apps/web/cypress/e2e/login-smoke.feature deleted file mode 100644 index 0d0ef5c..0000000 --- a/apps/web/cypress/e2e/login-smoke.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Login screen (throwaway smoke test) - Just checking that Gherkin + Cypress actually work end to end with the - currently installed preprocessor version — not meant to stay in the - suite long-term. - - Scenario: A visitor can type their credentials into the login screen - Given I am not signed in - When I visit "/login" - And I fill in the "email" field with "alice@example.com" - And I fill in the "password" field with "correct-horse-battery-staple" - Then the "email" field should have the value "alice@example.com" - And the "password" field should have the value "correct-horse-battery-staple" diff --git a/apps/web/cypress/e2e/login-smoke.ts b/apps/web/cypress/e2e/login-smoke.ts deleted file mode 100644 index edcf30f..0000000 --- a/apps/web/cypress/e2e/login-smoke.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; - -// Throwaway smoke test — see login-smoke.feature. Steps are deliberately -// minimal/self-contained here rather than shared, since this whole file is -// meant to be deleted once it's proven the preprocessor works. - -Given("I am not signed in", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); -}); - -When("I visit {string}", (path: string) => { - cy.visit(path); -}); - -When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { - cy.get(`#${fieldId}`).type(value); -}); - -Then("the {string} field should have the value {string}", (fieldId: string, value: string) => { - cy.get(`#${fieldId}`).should("have.value", value); -}); diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts deleted file mode 100644 index 93c2af8..0000000 --- a/apps/web/cypress/e2e/onboarding.cy.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no -// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover -// real API behavior against a real database). - -const signupResponse = { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: null as number | null, - dietId: null, -}; - -/** Signs up and lands on the wizard's first step (regime) — shared setup for every scenario below. */ -function signupAndReachOnboarding() { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); - cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); - - cy.visit("/signup"); - cy.get("#firstName").type("Alice"); - cy.get("#lastName").type("Martin"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Créer mon profil").click(); - cy.wait("@signup"); -} - -describe("Onboarding wizard (regime → foyer → allergens)", () => { - it("walks through all three steps, creating a household on the way, and lands on the home", () => { - cy.intercept("GET", "**/reference/diets", { - statusCode: 200, - body: [ - { id: 1, key: "omnivore" }, - { id: 2, key: "vegetarian" }, - ], - }); - cy.intercept("PATCH", "**/profile/diet", { - statusCode: 200, - body: { ...signupResponse, dietId: 2 }, - }).as("updateDiet"); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("POST", "**/house", { - statusCode: 201, - body: { id: 1, name: "Chez Alice", adminId: 1, inviteCode: "ABCD2345", members: [] }, - }).as("createHouse"); - cy.intercept("GET", "**/reference/allergies", { - statusCode: 200, - body: [ - { id: 1, key: "peanuts", kind: "ALLERGY" }, - { id: 2, key: "gluten", kind: "INTOLERANCE" }, - ], - }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as( - "updateAllergies", - ); - - signupAndReachOnboarding(); - - // Step 1/3 — dietary regime. - cy.url().should("include", "/onboarding/regime"); - cy.contains("Étape 1 sur 3").should("be.visible"); - cy.get("#diet").select("Végétarien"); - cy.contains("button", "Continuer").click(); - cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 }); - - // Step 2/3 — household, optional: creating one here. - cy.url().should("include", "/onboarding/foyer"); - cy.contains("Étape 2 sur 3").should("be.visible"); - cy.get("#houseName").type("Chez Alice"); - cy.contains("button", "Créer").click(); - cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); - - // Step 3/3 — allergens (grouped into two lists) and intolerances, then finish. - cy.url().should("include", "/onboarding/allergenes"); - cy.contains("Étape 3 sur 3").should("be.visible"); - cy.contains("legend", "Allergies").should("be.visible"); - cy.contains("legend", "Intolérances").should("be.visible"); - cy.contains("label", "Arachides").find("input[type=checkbox]").check(); - cy.contains("button", "Terminer").click(); - cy.wait("@updateAllergies") - .its("request.body") - .should("deep.equal", { allergyIds: [1] }); - - cy.url().should("eq", `${Cypress.config().baseUrl}/`); - cy.contains("h1", "Planning de la semaine").should("be.visible"); - }); - - it("lets the regime and allergens steps be skipped without changing anything", () => { - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as( - "updateDiet", - ); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as( - "updateAllergies", - ); - - signupAndReachOnboarding(); - - cy.url().should("include", "/onboarding/regime"); - cy.contains("button", "Continuer").click(); - cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null }); - - cy.url().should("include", "/onboarding/foyer"); - cy.contains("button", "Passer cette étape").click(); - - cy.url().should("include", "/onboarding/allergenes"); - cy.contains("button", "Terminer").click(); - cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] }); - - cy.url().should("eq", `${Cypress.config().baseUrl}/`); - }); - - it("lets the household step be completed by joining an existing household instead of creating one", () => { - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }); - cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); - cy.intercept("POST", "**/house/join", { - statusCode: 200, - body: { - id: 1, - name: "Chez Bob", - adminId: 2, - inviteCode: "ABCD2345", - members: [ - { id: 1, firstName: "Alice", lastName: "Martin" }, - { id: 2, firstName: "Bob", lastName: "Dupont" }, - ], - }, - }).as("joinHouse"); - cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }); - - signupAndReachOnboarding(); - - cy.contains("button", "Continuer").click(); - cy.url().should("include", "/onboarding/foyer"); - - cy.get("#inviteCode").type("abcd2345"); - cy.contains("button", "Rejoindre").click(); - - cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" }); - cy.url().should("include", "/onboarding/allergenes"); - }); -}); diff --git a/apps/web/cypress/e2e/onboarding.feature b/apps/web/cypress/e2e/onboarding.feature new file mode 100644 index 0000000..c429a82 --- /dev/null +++ b/apps/web/cypress/e2e/onboarding.feature @@ -0,0 +1,67 @@ +Feature: Onboarding wizard + As a newly signed-up user + I want to set my diet, household, and allergens + So that my profile is ready before I start planning meals + + Background: + Given the planning request returns nothing + + Scenario: Walks through all three steps, creating a household on the way, and lands on the home page + Given the diets reference list has options + And selecting the diet will succeed + And the household request returns no household + And creating a household will succeed + And the allergies reference list has options + And updating allergies will succeed + And I have signed up + Then the URL should include "/onboarding/regime" + And I should see "Étape 1 sur 3" + When I select "Végétarien" from the "diet" field + And I click the button "Continuer" + Then the diet update request should have been made with diet id 2 + And the URL should include "/onboarding/foyer" + And I should see "Étape 2 sur 3" + When I fill in the "houseName" field with "Chez Alice" + And I click the button "Créer" + Then the household creation request should have been made with name "Chez Alice" + And the URL should include "/onboarding/allergenes" + And I should see "Étape 3 sur 3" + And I should see the section "Allergies" + And I should see the section "Intolérances" + When I check the checkbox "Arachides" + And I click the button "Terminer" + Then the allergies update request should have been made with allergy id 1 + And the URL should be the home page + And I should see the heading "Planning de la semaine" + + Scenario: Lets the regime and allergens steps be skipped without changing anything + Given the diets reference list is empty + And selecting the diet will succeed + And the household request returns no household + And the allergies reference list is empty + And updating allergies will succeed + And I have signed up + Then the URL should include "/onboarding/regime" + When I click the button "Continuer" + Then the diet update request should have been made with no diet id + And the URL should include "/onboarding/foyer" + When I click the button "Passer cette étape" + Then the URL should include "/onboarding/allergenes" + When I click the button "Terminer" + Then the allergies update request should have been made with no allergy ids + And the URL should be the home page + + Scenario: Lets the household step be completed by joining an existing household instead of creating one + Given the diets reference list is empty + And selecting the diet will succeed + And the household request returns no household + And joining a household will succeed + And the allergies reference list is empty + And updating allergies will succeed + And I have signed up + When I click the button "Continuer" + Then the URL should include "/onboarding/foyer" + When I fill in the "inviteCode" field with "abcd2345" + And I click the button "Rejoindre" + Then the household join request should have been made with invite code "ABCD2345" + And the URL should include "/onboarding/allergenes" diff --git a/apps/web/cypress/e2e/onboarding.ts b/apps/web/cypress/e2e/onboarding.ts new file mode 100644 index 0000000..ff922ba --- /dev/null +++ b/apps/web/cypress/e2e/onboarding.ts @@ -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: [] }); +}); diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index ab13c85..bfb4af3 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -1,6 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no -// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover -// real API behavior against a real database). +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The logout journey (also reachable from here, via the account menu) moved +// to auth.feature — same behavior, no need to cover it twice. const authenticatedProfile = { id: 1, @@ -47,16 +50,6 @@ describe("Sidebar navigation", () => { cy.url().should("eq", `${Cypress.config().baseUrl}/`); cy.contains("h1", "Planning de la semaine").should("be.visible"); }); - - it("shows the signed-in user's name and lets them log out from the account menu", () => { - cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); - - cy.contains("button", "Bonjour Alice").should("be.visible").click(); - cy.contains("button", "Se déconnecter").click(); - - cy.wait("@logout"); - cy.url().should("include", "/login"); - }); }); describe("Planning grid", () => { diff --git a/apps/web/cypress/e2e/preferences.cy.ts b/apps/web/cypress/e2e/preferences.cy.ts index 65f3111..439bb91 100644 --- a/apps/web/cypress/e2e/preferences.cy.ts +++ b/apps/web/cypress/e2e/preferences.cy.ts @@ -1,4 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The autosave journeys (regime, allergies) moved to preferences.feature — +// this file now only covers the page's initial display. const authenticatedProfile = { id: 1, @@ -52,30 +57,4 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => { cy.visit("/parametres/preferences"); cy.contains("button", "Enregistrer").should("not.exist"); }); - - it("autosaves the regime as soon as it's selected", () => { - cy.intercept("PATCH", "**/profile/diet", { - statusCode: 200, - body: { ...authenticatedProfile, dietId: 1 }, - }).as("updateDiet"); - - cy.visit("/parametres/preferences"); - cy.get("#diet").select("Omnivore"); - - cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 }); - }); - - it("autosaves allergies and intolerances together after checking boxes", () => { - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as( - "updateAllergies", - ); - - cy.visit("/parametres/preferences"); - cy.contains("label", "Arachides").find("input[type=checkbox]").check(); - - cy.wait("@updateAllergies") - .its("request.body") - .should("deep.equal", { allergyIds: [2, 1] }); - cy.contains("Enregistré ✓").should("be.visible"); - }); }); diff --git a/apps/web/cypress/e2e/preferences.feature b/apps/web/cypress/e2e/preferences.feature new file mode 100644 index 0000000..2c23866 --- /dev/null +++ b/apps/web/cypress/e2e/preferences.feature @@ -0,0 +1,23 @@ +Feature: Dietary preferences + As a signed-in user + I want my regime and allergies/intolerances to autosave as I edit them + So that my preferences are always up to date without an explicit save step + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And my diet id is 2 + And the dietary preferences reference data is ready + + Scenario: Autosaves the regime as soon as it's selected + Given selecting the diet will succeed + When I visit "/parametres/preferences" + And I select "Omnivore" from the "diet" field + Then the diet update request should have been made with diet id 1 + + Scenario: Autosaves allergies and intolerances together after checking boxes + Given updating allergies will succeed + When I visit "/parametres/preferences" + And I check the checkbox "Arachides" + Then the allergies update request should have been made with allergy ids 2 and 1 + And I should see "Enregistré ✓" diff --git a/apps/web/cypress/e2e/preferences.ts b/apps/web/cypress/e2e/preferences.ts new file mode 100644 index 0000000..30e5809 --- /dev/null +++ b/apps/web/cypress/e2e/preferences.ts @@ -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] }); + }, +); diff --git a/apps/web/cypress/e2e/recipe-form.cy.ts b/apps/web/cypress/e2e/recipe-form.cy.ts deleted file mode 100644 index 44ae9fb..0000000 --- a/apps/web/cypress/e2e/recipe-form.cy.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no -// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover -// real API behavior against a real database). - -const authenticatedProfile = { - id: 1, - firstName: "Alice", - lastName: "Martin", - email: "alice@example.com", - tokenVersion: 0, - houseId: 1, - dietId: null, -}; - -const tomato = { - id: 1, - key: "tomato", - icon: "VEGETABLE", - category: "PRODUITS_FRAIS", - subcategory: "LEGUMES", - allergens: [], - diets: [{ id: 2, key: "vegetarian" }], -}; -const egg = { - id: 2, - key: "egg", - icon: "EGG", - category: "CREMERIE_FROMAGE", - subcategory: "OEUFS", - allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], - diets: [], -}; -const carrot = { - id: 3, - key: "carrot", - icon: "VEGETABLE", - category: "PRODUITS_FRAIS", - subcategory: "LEGUMES", - allergens: [], - diets: [{ id: 2, key: "vegetarian" }], -}; - -const diets = [ - { id: 1, key: "omnivore" }, - { id: 2, key: "vegetarian" }, -]; - -function interceptCatalog() { - cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); - cy.intercept("GET", "**/reference/ingredients", { - statusCode: 200, - body: [tomato, egg, carrot], - }); - cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); -} - -describe("Recipe form — associating ingredients", () => { - beforeEach(() => { - interceptCatalog(); - }); - - it("adds an ingredient from the picker, fills its quantity/unit, and creates the recipe", () => { - cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id: 42 } }).as("createRecipe"); - - cy.visit("/recettes/nouvelle"); - - cy.get("#recipe-name").type("Salade de tomates"); - - cy.get("input[placeholder='Rechercher un ingrédient…']").type("tomat"); - cy.contains(".ingredient-picker__card", "Tomate").click(); - - // The card disappears from the picker once selected (excludeIds), and - // a row for it appears in the recipe's own ingredient list. - cy.contains(".ingredient-picker__card", "Tomate").should("not.exist"); - cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); - - cy.get(".ingredient-row .ingredient-row__quantity").type("3"); - cy.get(".ingredient-row .ingredient-row__unit").type("unité"); - - cy.contains("button", "Ajouter une étape").click(); - cy.get(".step-list-editor__item textarea").type("Couper les tomates."); - - cy.contains("button", "Enregistrer").should("not.be.disabled").click(); - - cy.wait("@createRecipe") - .its("request.body") - .should("deep.include", { - name: "Salade de tomates", - ingredients: [{ ingredientId: 1, quantity: 3, unit: "unité" }], - }); - cy.url().should("include", "/recettes/42"); - }); - - // Regression test for the exact bug reported: `crypto.randomUUID()` (used - // to mint each ingredient/step draft's client-only React key) throws - // outside a secure context — https, or literally the hostname - // `localhost` — so a LAN IP during on-device testing or a Capacitor - // WebView's `capacitor://` origin hit a black screen with "TypeError: - // crypto.randomUUID is not a function" the instant an ingredient was - // added. Cypress's own origin is secure, so this forces the same failure - // by deleting `crypto.randomUUID` before the app boots — see - // `apps/web/src/lib/client-key.ts`, which replaced it. - it("still works when crypto.randomUUID is unavailable (insecure-context regression)", () => { - cy.visit("/recettes/nouvelle", { - onBeforeLoad(win) { - Object.defineProperty(win.crypto, "randomUUID", { - value: undefined, - configurable: true, - }); - }, - }); - - cy.get("#recipe-name").type("Recette hors contexte sécurisé"); - - cy.contains(".ingredient-picker__card", "Tomate").click(); - cy.contains(".ingredient-picker__card", "Œuf").click(); - - // Both rows rendered with distinct identities — no crash, no React - // "same key" warning silently collapsing one of them. - cy.get(".ingredient-row").should("have.length", 2); - cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); - cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); - - cy.contains("button", "Ajouter une étape").click(); - cy.contains("button", "Ajouter une étape").click(); - cy.get(".step-list-editor__item").should("have.length", 2); - }); - - it("excludes an already-selected ingredient from the picker, and removing it brings it back", () => { - cy.visit("/recettes/nouvelle"); - - cy.contains(".ingredient-picker__card", "Carotte").click(); - cy.contains(".ingredient-picker__card", "Carotte").should("not.exist"); - - cy.contains(".ingredient-row", "Carotte") - .find("button[title='Retirer cet ingrédient']") - .click(); - cy.contains(".ingredient-picker__card", "Carotte").should("be.visible"); - cy.get(".ingredient-row").should("have.length", 0); - }); - - it("preloads an existing recipe's ingredients when editing, and lets you add another", () => { - const existingRecipe = { - id: 7, - name: "Omelette", - description: null, - picture: null, - authorId: 1, - visibility: "PERSONAL", - allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], - diets: [], - isFavorite: false, - ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], - steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], - }; - cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); - cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe"); - - cy.visit("/recettes/7/modifier"); - - cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); - cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", "3"); - - cy.contains(".ingredient-picker__card", "Tomate").click(); - cy.get(".ingredient-row").should("have.length", 2); - cy.get(".ingredient-row .ingredient-row__quantity").last().type("1"); - cy.get(".ingredient-row .ingredient-row__unit").last().type("unité"); - - cy.contains("button", "Enregistrer").click(); - - cy.wait("@updateRecipe") - .its("request.body.ingredients") - .should("deep.equal", [ - { ingredientId: 2, quantity: 3, unit: "unité" }, - { ingredientId: 1, quantity: 1, unit: "unité" }, - ]); - }); -}); diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature new file mode 100644 index 0000000..33cd1fc --- /dev/null +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -0,0 +1,69 @@ +Feature: Recipe form — associating ingredients + As a signed-in user + I want to build a recipe by picking ingredients, quantities, and steps + So that I can save a complete recipe in one form + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the ingredient/diet catalog is available + + Scenario: Adds an ingredient from the picker, fills its quantity/unit, and creates the recipe + Given creating the recipe will succeed and return id 42 + When I visit "/recettes/nouvelle" + And I fill in the "recipe-name" field with "Salade de tomates" + And I search the ingredient picker for "tomat" + And I select the ingredient "Tomate" from the picker + Then the ingredient "Tomate" should no longer be in the picker + And the recipe should include the ingredient "Tomate" + When I fill in the ingredient's quantity with "3" and unit "unité" + And I add a step + And I fill in the step description with "Couper les tomates." + Then the "Enregistrer" button should not be disabled + When I click the button "Enregistrer" + Then the recipe creation request should have included name "Salade de tomates" and ingredient 1 with quantity 3 and unit "unité" + And the URL should include "/recettes/42" + + # Regression test for the exact bug reported: `crypto.randomUUID()` (used + # to mint each ingredient/step draft's client-only React key) throws + # outside a secure context — https, or literally the hostname `localhost` + # — so a LAN IP during on-device testing or a Capacitor WebView's + # `capacitor://` origin hit a black screen with "TypeError: + # crypto.randomUUID is not a function" the instant an ingredient was + # added. Cypress's own origin is secure, so this forces the same failure + # by deleting `crypto.randomUUID` before the app boots — see + # `apps/web/src/lib/client-key.ts`, which replaced it. + Scenario: Still works when crypto.randomUUID is unavailable (insecure-context regression) + When I visit the new recipe form without a secure random UUID + And I fill in the "recipe-name" field with "Recette hors contexte sécurisé" + And I select the ingredient "Tomate" from the picker + And I select the ingredient "Œuf" from the picker + Then there should be 2 ingredient rows + And the recipe should include the ingredient "Tomate" + And the recipe should include the ingredient "Œuf" + When I add a step + And I add a step + Then there should be 2 step editor items + + Scenario: Excludes an already-selected ingredient from the picker, and removing it brings it back + When I visit "/recettes/nouvelle" + And I select the ingredient "Carotte" from the picker + Then the ingredient "Carotte" should no longer be in the picker + When I remove the ingredient "Carotte" from the recipe + Then the ingredient "Carotte" should be visible in the picker + And there should be 0 ingredient rows + + Scenario: Preloads an existing recipe's ingredients when editing, and lets you add another + Given recipe 7 exists with an egg omelette + And updating recipe 7 will succeed + When I visit "/recettes/7/modifier" + Then the recipe should include the ingredient "Œuf" + And the ingredient's quantity should be "3" + When I select the ingredient "Tomate" from the picker + Then there should be 2 ingredient rows + When I fill in the last ingredient's quantity with "1" and unit "unité" + And I click the button "Enregistrer" + Then the recipe update request should have included these ingredients: + | ingredientId | quantity | unit | + | 2 | 3 | unité | + | 1 | 1 | unité | diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts new file mode 100644 index 0000000..4a8ec69 --- /dev/null +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -0,0 +1,159 @@ +import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +const tomato = { + id: 1, + key: "tomato", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; +const egg = { + id: 2, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], +}; +const carrot = { + id: 3, + key: "carrot", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; + +const diets = [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, +]; + +Given("the ingredient/diet catalog is available", () => { + cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] }); + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); +}); + +Given("creating the recipe will succeed and return id {int}", (id: number) => { + cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id } }).as("createRecipe"); +}); + +When("I visit the new recipe form without a secure random UUID", () => { + cy.visit("/recettes/nouvelle", { + onBeforeLoad(win) { + Object.defineProperty(win.crypto, "randomUUID", { + value: undefined, + configurable: true, + }); + }, + }); +}); + +When("I search the ingredient picker for {string}", (text: string) => { + cy.get("input[placeholder='Rechercher un ingrédient…']").type(text); +}); + +When("I select the ingredient {string} from the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).click(); +}); + +Then("the ingredient {string} should no longer be in the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).should("not.exist"); +}); + +Then("the ingredient {string} should be visible in the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).should("be.visible"); +}); + +Then("the recipe should include the ingredient {string}", (name: string) => { + cy.contains(".ingredient-row__name", name).should("be.visible"); +}); + +Then("there should be {int} ingredient rows", (count: number) => { + cy.get(".ingredient-row").should("have.length", count); +}); + +When("I remove the ingredient {string} from the recipe", (name: string) => { + cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click(); +}); + +When( + "I fill in the ingredient's quantity with {string} and unit {string}", + (quantity: string, unit: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").type(quantity); + cy.get(".ingredient-row .ingredient-row__unit").type(unit); + }, +); + +Then("the ingredient's quantity should be {string}", (quantity: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity); +}); + +When( + "I fill in the last ingredient's quantity with {string} and unit {string}", + (quantity: string, unit: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity); + cy.get(".ingredient-row .ingredient-row__unit").last().type(unit); + }, +); + +When("I add a step", () => { + cy.contains("button", "Ajouter une étape").click(); +}); + +When("I fill in the step description with {string}", (text: string) => { + cy.get(".step-list-editor__item textarea").type(text); +}); + +Then("there should be {int} step editor items", (count: number) => { + cy.get(".step-list-editor__item").should("have.length", count); +}); + +Then( + "the recipe creation request should have included name {string} and ingredient {int} with quantity {int} and unit {string}", + (name: string, ingredientId: number, quantity: number, unit: string) => { + cy.wait("@createRecipe") + .its("request.body") + .should("deep.include", { + name, + ingredients: [{ ingredientId, quantity, unit }], + }); + }, +); + +Given("recipe 7 exists with an egg omelette", () => { + const existingRecipe = { + id: 7, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], + isFavorite: false, + ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], + steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], + }; + cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); +}); + +Given("updating recipe 7 will succeed", () => { + cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe"); +}); + +Then( + "the recipe update request should have included these ingredients:", + (dataTable: DataTable) => { + const expected = dataTable.hashes().map((row) => ({ + ingredientId: Number(row.ingredientId), + quantity: Number(row.quantity), + unit: row.unit, + })); + cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected); + }, +); diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index f7a065c..955f14e 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -1,6 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no -// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover -// real API behavior against a real database). +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The favorite-toggle and delete-recipe journeys moved to recipes.feature — +// this file now only covers catalog browsing/display. const authenticatedProfile = { id: 1, @@ -200,41 +203,6 @@ describe("Recipe catalog", () => { cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible"); }); - it("toggles a recipe's favorite from the detail panel and reflects it in the table", () => { - cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); - cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); - cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite"); - - cy.visit("/recettes/2"); - - cy.contains(".recipe-table__name", "Omelette") - .find(".recipe-table__fav-mark") - .should("not.exist"); - - cy.get(".favorite-star-button").click(); - cy.wait("@favorite"); - - cy.get(".favorite-star-button").should("have.class", "is-favorite"); - cy.contains(".recipe-table__name", "Omelette").find(".recipe-table__fav-mark").should("exist"); - }); - - it("deletes a recipe after a two-step confirmation, then clears the selection", () => { - cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); - cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); - cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); - - cy.visit("/recettes/2"); - cy.contains(".recipe-detail-panel", "Omelette").should("be.visible"); - - cy.contains(".recipe-detail-panel__danger-button", "Supprimer").click(); - cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click(); - cy.wait("@deleteRecipe"); - - cy.url().should("match", /\/recettes\/?$/); - cy.contains(".recipe-table__name", "Omelette").should("not.exist"); - cy.contains("Sélectionnez une recette dans le tableau").should("be.visible"); - }); - it("links the new-recipe button to the recipe form", () => { cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); diff --git a/apps/web/cypress/e2e/recipes.feature b/apps/web/cypress/e2e/recipes.feature new file mode 100644 index 0000000..18a0ea4 --- /dev/null +++ b/apps/web/cypress/e2e/recipes.feature @@ -0,0 +1,33 @@ +Feature: Managing a recipe from the catalog + As a signed-in user + I want to favorite or delete one of my recipes + So that I can curate my catalog as it grows + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the disliked ingredients list is empty + + Scenario: Toggles a recipe's favorite from the detail panel and reflects it in the table + Given the recipe catalog contains "Omelette" + And recipe 2's detail is available + And toggling recipe 2's favorite will succeed + When I visit "/recettes/2" + Then the recipe "Omelette" should not be marked as favorite + When I click the favorite star + Then the favorite request should have been made + And the favorite star should be marked as favorite + And the recipe "Omelette" should be marked as favorite + + Scenario: Deletes a recipe after a two-step confirmation, then clears the selection + Given the recipe catalog contains "Omelette" + And recipe 2's detail is available + And deleting recipe 2 will succeed + When I visit "/recettes/2" + Then the recipe detail panel heading should be "Omelette" + When I click "Supprimer" in the recipe detail panel + And I confirm the deletion in the recipe detail panel + Then the delete request should have been made + And the URL should match the recipes list + And the recipe "Omelette" should not be visible in the table + And I should see "Sélectionnez une recette dans le tableau" diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts new file mode 100644 index 0000000..02c0e80 --- /dev/null +++ b/apps/web/cypress/e2e/recipes.ts @@ -0,0 +1,103 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" }; + +const omelette = { + id: 2, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [oeufs], + diets: [], + isFavorite: false, +}; + +const omeletteDetail = { + ...omelette, + description: "Une omelette toute simple.", + ingredients: [ + { + ingredient: { + id: 10, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [oeufs], + diets: [], + }, + quantity: 3, + unit: "unité", + }, + ], + steps: [ + { id: 1, description: "Battre les œufs.", picture: null, order: 1 }, + { id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, + ], +}; + +Given("the disliked ingredients list is empty", () => { + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); +}); + +Given("the recipe catalog contains {string}", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); +}); + +Given("recipe 2's detail is available", () => { + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe"); +}); + +Given("toggling recipe 2's favorite will succeed", () => { + cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite"); +}); + +Given("deleting recipe 2 will succeed", () => { + cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); +}); + +Then("the recipe {string} should not be visible in the table", (name: string) => { + cy.contains(".recipe-table__name", name).should("not.exist"); +}); + +Then("the recipe {string} should be marked as favorite", (name: string) => { + cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("exist"); +}); + +Then("the recipe {string} should not be marked as favorite", (name: string) => { + cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("not.exist"); +}); + +Then("the recipe detail panel heading should be {string}", (text: string) => { + cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible"); +}); + +When("I click the favorite star", () => { + cy.get(".favorite-star-button").click(); +}); + +Then("the favorite request should have been made", () => { + cy.wait("@favorite"); +}); + +Then("the favorite star should be marked as favorite", () => { + cy.get(".favorite-star-button").should("have.class", "is-favorite"); +}); + +When("I click {string} in the recipe detail panel", (text: string) => { + cy.contains(".recipe-detail-panel__danger-button", text).click(); +}); + +When("I confirm the deletion in the recipe detail panel", () => { + cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click(); +}); + +Then("the delete request should have been made", () => { + cy.wait("@deleteRecipe"); +}); + +Then("the URL should match the recipes list", () => { + cy.url().should("match", /\/recettes\/?$/); +}); diff --git a/apps/web/cypress/e2e/sidebar.cy.ts b/apps/web/cypress/e2e/sidebar.cy.ts index 2d69946..56d38e3 100644 --- a/apps/web/cypress/e2e/sidebar.cy.ts +++ b/apps/web/cypress/e2e/sidebar.cy.ts @@ -1,4 +1,6 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. const authenticatedProfile = { id: 1, diff --git a/apps/web/cypress/e2e/user-preferences.cy.ts b/apps/web/cypress/e2e/user-preferences.cy.ts index 0c09b1c..5368f72 100644 --- a/apps/web/cypress/e2e/user-preferences.cy.ts +++ b/apps/web/cypress/e2e/user-preferences.cy.ts @@ -1,4 +1,9 @@ -// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// The theme-switching journey moved to user-preferences.feature — this file +// now only covers the page's initial display (default/saved theme). const authenticatedProfile = { id: 1, @@ -35,20 +40,4 @@ describe("User preferences (/parametres/preferences-utilisateur)", () => { cy.contains("label", "Sombre").find("input[type=radio]").should("be.checked"); cy.get("html").should("have.attr", "data-theme", "dark"); }); - - it("switching theme autosaves and applies immediately, no explicit save button", () => { - cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "SYSTEM" } }); - cy.intercept("PATCH", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }).as( - "updatePreferences", - ); - - cy.visit("/parametres/preferences-utilisateur"); - cy.contains("button", "Enregistrer").should("not.exist"); - - cy.contains("label", "Clair").click(); - - cy.wait("@updatePreferences").its("request.body").should("deep.equal", { theme: "LIGHT" }); - cy.contains("Enregistré ✓").should("be.visible"); - cy.get("html").should("have.attr", "data-theme", "light"); - }); }); diff --git a/apps/web/cypress/e2e/user-preferences.feature b/apps/web/cypress/e2e/user-preferences.feature new file mode 100644 index 0000000..4f4b996 --- /dev/null +++ b/apps/web/cypress/e2e/user-preferences.feature @@ -0,0 +1,17 @@ +Feature: User preferences (theme) + As a signed-in user + I want to switch between system/light/dark theme + So that the app matches my preference, applied immediately + + Background: + Given I am signed in as "Alice" "Martin" + + Scenario: Switching theme autosaves and applies immediately, no explicit save button + Given my saved theme preference is "SYSTEM" + And updating the theme preference will succeed + When I visit "/parametres/preferences-utilisateur" + Then I should not see "Enregistrer" + When I click the radio "Clair" + Then the theme update request should have been made with theme "LIGHT" + And I should see "Enregistré ✓" + And the page theme should be "light" diff --git a/apps/web/cypress/e2e/user-preferences.ts b/apps/web/cypress/e2e/user-preferences.ts new file mode 100644 index 0000000..e888bcd --- /dev/null +++ b/apps/web/cypress/e2e/user-preferences.ts @@ -0,0 +1,15 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +Given("my saved theme preference is {string}", (theme: string) => { + cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme } }); +}); + +Given("updating the theme preference will succeed", () => { + cy.intercept("PATCH", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }).as( + "updatePreferences", + ); +}); + +Then("the theme update request should have been made with theme {string}", (theme: string) => { + cy.wait("@updatePreferences").its("request.body").should("deep.equal", { theme }); +}); diff --git a/apps/web/cypress/support/profile.ts b/apps/web/cypress/support/profile.ts new file mode 100644 index 0000000..a0236ed --- /dev/null +++ b/apps/web/cypress/support/profile.ts @@ -0,0 +1,33 @@ +import type { SafeUserProfile } from "@batch-cooking/shared"; + +/** + * Mutable per-scenario signed-in profile, built up across several `Given` + * steps (see common.steps.ts's "I am signed in as .../my household id + * is.../my diet id is...") before the final `cy.visit` — each step + * re-registers the `GET **\/auth/me` intercept with the updated shape, so + * only the last one (i.e. the fully assembled profile) is ever actually + * requested by the app. Reset before every scenario by the `Before` hook in + * common.steps.ts, so scenarios never leak state into one another. + */ +export let currentProfile: SafeUserProfile | null = null; + +export function resetProfile() { + currentProfile = null; +} + +export function buildProfile(overrides: Partial = {}): SafeUserProfile { + return { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null, + dietId: null, + ...overrides, + }; +} + +export function setCurrentProfile(profile: SafeUserProfile) { + currentProfile = profile; +} diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts new file mode 100644 index 0000000..b428189 --- /dev/null +++ b/apps/web/cypress/support/step_definitions/common.steps.ts @@ -0,0 +1,191 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; +import { buildProfile, currentProfile, resetProfile, setCurrentProfile } from "../profile"; + +// Steps shared across every feature — signing in/out, navigation, and +// generic UI assertions/interactions phrased the same way regardless of +// which page they happen to run against. Anything specific to one feature +// (its own API responses, its own DOM structure) lives in that feature's +// own `.steps.ts` instead — same split as apps/api's +// step-definitions/ (shared "profile already exists" vs. feature-specific +// steps). +// +// Mocks the API via `cy.intercept` — this job doesn't run a live backend +// (see .github/workflows/ci.yml); apps/api's own Mocha/Cucumber suites +// cover real API behavior against a real database. + +Given("I am not signed in", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 401 }); +}); + +// No `Before()` hook for this reset (deliberately) — registering any +// Cucumber hook makes the preprocessor's browser runtime read +// `messages.HookType.{BEFORE,AFTER}_TEST_CASE` to report it, and that enum +// doesn't exist on the older, CommonJS-only `@cucumber/messages` this repo +// is pinned to (see `pnpm.overrides` in package.json, and this branch's +// commit history for why) — every scenario crashed on "Cannot read +// properties of undefined (reading 'BEFORE_TEST_CASE')" the moment this +// file registered one. Resetting right here instead, at the one step every +// profile-building chain always starts with, is equivalent for our +// purposes without needing a hook at all. +Given("I am signed in as {string} {string}", (firstName: string, lastName: string) => { + resetProfile(); + setCurrentProfile( + buildProfile({ + firstName, + lastName, + email: `${firstName.toLowerCase()}@example.com`, + }), + ); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +// Composable with the step above — re-registers the same intercept with an +// updated profile field. Order between these doesn't matter as long as they +// all run before the scenario's `visit`/`When` step: Cypress resolves +// multiple `cy.intercept` calls on the same route by giving the +// most-recently-registered one priority, so the final, fully-assembled +// profile is always what the app actually receives. +Given("my household id is {int}", (houseId: number) => { + if (!currentProfile) { + throw new Error('"my household id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, houseId }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +Given("my diet id is {int}", (dietId: number) => { + if (!currentProfile) { + throw new Error('"my diet id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, dietId }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +Given("my user id is {int}", (id: number) => { + if (!currentProfile) { + throw new Error('"my user id is" must follow "I am signed in as ..."'); + } + setCurrentProfile({ ...currentProfile, id }); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile }); +}); + +When("I visit {string}", (path: string) => { + cy.visit(path); +}); + +Then("the URL should include {string}", (fragment: string) => { + cy.url().should("include", fragment); +}); + +Then("the URL should not include {string}", (fragment: string) => { + cy.url().should("not.include", fragment); +}); + +Then("the URL should be the home page", () => { + cy.url().should("eq", `${Cypress.config().baseUrl}/`); +}); + +Then("I should see {string}", (text: string) => { + cy.contains(text).should("be.visible"); +}); + +Then("I should see the heading {string}", (text: string) => { + cy.contains("h1", text).should("be.visible"); +}); + +Then("I should not see {string}", (text: string) => { + cy.contains(text).should("not.exist"); +}); + +When("I click the button {string}", (text: string) => { + cy.contains("button", text).click(); +}); + +When("I click the link {string}", (text: string) => { + cy.contains("a", text).click(); +}); + +When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { + cy.get(`#${fieldId}`).type(value); +}); + +When("I clear the {string} field", (fieldId: string) => { + cy.get(`#${fieldId}`).clear(); +}); + +Then("the {string} field should have the value {string}", (fieldId: string, value: string) => { + cy.get(`#${fieldId}`).should("have.value", value); +}); + +When("I select {string} from the {string} field", (value: string, fieldId: string) => { + cy.get(`#${fieldId}`).select(value); +}); + +Then("I should see the section {string}", (legend: string) => { + cy.contains("legend", legend).should("be.visible"); +}); + +Then("the checkbox {string} should be checked", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").should("be.checked"); +}); + +Then("the checkbox {string} should not be checked", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").should("not.be.checked"); +}); + +When("I check the checkbox {string}", (label: string) => { + cy.contains("label", label).find("input[type=checkbox]").check(); +}); + +Then("the radio {string} should be checked", (label: string) => { + cy.contains("label", label).find("input[type=radio]").should("be.checked"); +}); + +Then("the radio {string} should not be checked", (label: string) => { + cy.contains("label", label).find("input[type=radio]").should("not.be.checked"); +}); + +When("I click the radio {string}", (label: string) => { + cy.contains("label", label).click(); +}); + +Then("the page should have no theme override", () => { + cy.get("html").should("not.have.attr", "data-theme"); +}); + +Then("the page theme should be {string}", (theme: string) => { + cy.get("html").should("have.attr", "data-theme", theme); +}); + +// Freezes `Date` so "today"/"this week" assertions are deterministic +// instead of depending on the day the suite happens to run. +Given("today is frozen at {string}", (iso: string) => { + cy.clock(new Date(iso), ["Date"]); +}); + +Given("the viewport is {int} by {int}", (width: number, height: number) => { + cy.viewport(width, height); +}); + +Then("the {string} button should not be disabled", (text: string) => { + cy.contains("button", text).should("not.be.disabled"); +}); + +When("I open the account menu", () => { + cy.get(".app-sidebar__account-toggle").click(); +}); + +// Not "**/planning*" — that glob also matches the Vite dev request for +// planning-page.scss. Used by any feature that lands on the home page +// (Planning) but isn't itself testing the planning grid's content. +Given("the planning request returns nothing", () => { + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); +}); + +Given("the household request returns no household", () => { + cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); +}); + +Then("the link {string} should point to {string}", (text: string, href: string) => { + cy.contains("a", text).should("have.attr", "href", href); +});