test(web): refactor user journeys into Cucumber scenarios

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

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

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

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

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

Vérifié statiquement : les 246 steps Gherkin des 8 .feature résolvent
chacun vers exactement une définition (0 non résolu, 0 ambigu) et
`pnpm exec biome check` est propre sur tout cypress/. Reste à confirmer
en CI que les scénarios passent réellement (pas seulement qu'ils se
résolvent).
This commit is contained in:
Nicolas 2026-08-19 13:38:20 +02:00
parent a3c242bfa5
commit aaace15b51
30 changed files with 1258 additions and 781 deletions

View file

@ -1,6 +1,9 @@
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); apps/api's own Mocha suite covers real API
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. // 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 = { const authenticatedProfile = {
id: 1, id: 1,
@ -24,45 +27,4 @@ describe("Account settings (/parametres/compte)", () => {
cy.contains("Martin").should("be.visible"); cy.contains("Martin").should("be.visible");
cy.contains("alice@example.com").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);
});
}); });

View file

@ -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

View 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);
});

View file

@ -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");
});
});

View file

@ -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"

View 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");
});

View file

@ -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 = { const adminProfile = {
id: 1, 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("Créer un foyer").should("be.visible");
cy.contains("Rejoindre 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", () => { 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("Bob Dupont").should("be.visible");
cy.contains("Alice Martin").parent().contains("Admin"); 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", () => { 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", "Quitter le foyer").should("be.visible");
cy.contains("button", "Supprimer le foyer").should("not.exist"); 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");
});
}); });

View file

@ -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

View file

@ -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");
});

View file

@ -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"

View file

@ -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);
});

View file

@ -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");
});
});

View 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"

View 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: [] });
});

View file

@ -1,6 +1,9 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no // Mocks the API via cy.intercept — this job doesn't run a live backend (see
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover // .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// real API behavior against a real database). // 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 = { const authenticatedProfile = {
id: 1, id: 1,
@ -47,16 +50,6 @@ describe("Sidebar navigation", () => {
cy.url().should("eq", `${Cypress.config().baseUrl}/`); cy.url().should("eq", `${Cypress.config().baseUrl}/`);
cy.contains("h1", "Planning de la semaine").should("be.visible"); 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", () => { describe("Planning grid", () => {

View file

@ -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 = { const authenticatedProfile = {
id: 1, id: 1,
@ -52,30 +57,4 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => {
cy.visit("/parametres/preferences"); cy.visit("/parametres/preferences");
cy.contains("button", "Enregistrer").should("not.exist"); 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");
});
}); });

View file

@ -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é "

View 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] });
},
);

View file

@ -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é" },
]);
});
});

View 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é |

View 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);
},
);

View file

@ -1,6 +1,9 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no // Mocks the API via cy.intercept — this job doesn't run a live backend (see
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover // .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// real API behavior against a real database). // 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 = { const authenticatedProfile = {
id: 1, id: 1,
@ -200,41 +203,6 @@ describe("Recipe catalog", () => {
cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible"); 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", () => { it("links the new-recipe button to the recipe form", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });

View file

@ -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"

View file

@ -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\/?$/);
});

View file

@ -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 = { const authenticatedProfile = {
id: 1, id: 1,

View file

@ -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 = { const authenticatedProfile = {
id: 1, id: 1,
@ -35,20 +40,4 @@ describe("User preferences (/parametres/preferences-utilisateur)", () => {
cy.contains("label", "Sombre").find("input[type=radio]").should("be.checked"); cy.contains("label", "Sombre").find("input[type=radio]").should("be.checked");
cy.get("html").should("have.attr", "data-theme", "dark"); 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");
});
}); });

View file

@ -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"

View 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 });
});

View 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;
}

View file

@ -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 `<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.
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);
});