chore(api): retire l'intégration Cucumber/Gherkin

Retire la suite BDD Gherkin (apps/api/features/) — pas cassée, mais
mise de côté pour l'instant en même temps que l'intégration côté
Cypress (voir PR #23, fermée). À reprendre plus tard, probablement
avec une approche différente des deux côtés.

Vérifié avant retrait que la suite Mocha (apps/api/test/) couvre déjà
les mêmes scénarios, domaine par domaine (santé, référentiels,
authentification, profil, foyer, planning, recettes, préférences) —
souvent avec plus de détail (ex. reference.feature ne couvre pas
/reference/ingredients, contrairement à test/reference.test.ts). Pas
de perte de couverture réelle.

- apps/api/features/ (scénarios .feature + step-definitions + world/hooks)
  et cucumber.cjs supprimés
- apps/api/package.json : retire le script `test:bdd` et la
  dépendance @cucumber/cucumber (@faker-js/faker conservé — aussi
  utilisé par la suite Mocha)
- .github/workflows/ci.yml : retire l'étape `pnpm --filter api test:bdd`
- README : retire toute mention Cucumber/Gherkin/BDD (section dédiée,
  commandes, description du monorepo, note faker.js)

Le côté Cypress était déjà propre sur main (l'intégration testée sur
la branche feat/cypress-cucumber n'a jamais été mergée — PR fermée,
branche supprimée).
This commit is contained in:
Nicolas 2026-08-19 12:41:17 +02:00
parent c63bc15ea4
commit ffb1e06130
23 changed files with 16 additions and 1451 deletions

View file

@ -64,7 +64,6 @@ jobs:
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm --filter api exec prisma migrate deploy - run: pnpm --filter api exec prisma migrate deploy
- run: pnpm --filter api test - run: pnpm --filter api test
- run: pnpm --filter api test:bdd
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -4,7 +4,7 @@
Monorepo pnpm workspaces : Monorepo pnpm workspaces :
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha + Cucumber/BDD) - `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha)
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard. - `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
Page de connexion/inscription en place ; le reste est encore un squelette générique. Page de connexion/inscription en place ; le reste est encore un squelette générique.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`, - `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
@ -108,38 +108,12 @@ pnpm dev:web
pnpm lint # Biome (lint + format check) pnpm lint # Biome (lint + format check)
pnpm lint:fix # Biome --write pnpm lint:fix # Biome --write
pnpm test # tests unitaires/intégration (Mocha, apps/api) pnpm test # tests unitaires/intégration (Mocha, apps/api)
pnpm --filter api test:bdd # tests d'intégration BDD (Cucumber/Gherkin, apps/api)
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement) pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
pnpm build # build de tous les workspaces pnpm build # build de tous les workspaces
``` ```
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`). La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
### Cucumber (apps/api)
Tests d'intégration lisibles en Gherkin, en complément de Mocha (qui reste pour les
tests unitaires purs) :
- `apps/api/features/*.feature` — scénarios en Given/When/Then (`health.feature` sert
d'exemple)
- `apps/api/features/step-definitions/*.steps.ts` — implémentation des steps
- `apps/api/features/support/world.ts` — contexte partagé entre les steps d'un
scénario (instancie l'app Express in-process via `createApp()`, comme le fait déjà
supertest côté Mocha — pas besoin de lancer un vrai serveur)
- `apps/api/cucumber.cjs` — config (extension `.cjs` volontaire, voir la remarque
TypeScript/ESM ci-dessous)
Pour ajouter un scénario : écrire le `.feature`, lancer `pnpm --filter api test:bdd`,
implémenter les steps manquants (Cucumber affiche des snippets tout prêts pour ceux
qui n'existent pas encore).
> **Piège TypeScript/ESM à connaître** (déjà rencontré avec `cypress.config.ts`) :
> les fichiers de config d'outils tiers qui font du chargement dynamique de TS
> (`cucumber.cjs`, `cypress.config.ts`…) sont sensibles au `"type": "module"` du
> `package.json`. `cucumber.cjs` évite le problème *pour sa propre config* en étant
> explicitement CommonJS ; les steps/world restent en `.ts` ESM classique et sont
> chargés via `tsx` (`NODE_OPTIONS=--import=tsx`, voir le script `test:bdd`).
## Déploiement ## Déploiement
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
@ -204,14 +178,14 @@ encore implémenté).
> concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build` > concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build`
> qui passe ne suffit pas à détecter un crash runtime. > qui passe ne suffit pas à détecter un crash runtime.
Les tests (Mocha + Cucumber) tournent avec un coût argon2 réduit Les tests (Mocha) tournent avec un coût argon2 réduit
(`NODE_ENV=test`, voir `auth.service.ts`) — le coût par défaut est volontairement (`NODE_ENV=test`, voir `auth.service.ts`) — le coût par défaut est volontairement
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
`prisma migrate deploy` avant les tests. `prisma migrate deploy` avant les tests.
> **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.** > **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.**
> Lancer `pnpm test`/`test:bdd` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`, > Lancer `pnpm test` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`,
> voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main > voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle > (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
> efface tes données de test sans prévenir. Pas un bug, juste à savoir. > efface tes données de test sans prévenir. Pas un bug, juste à savoir.
@ -371,12 +345,10 @@ page fetch son propre profil frais (`apiClient.me()`) au montage, et
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du `AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
régime pour que le reste de l'app reste cohérent aussi. régime pour que le reste de l'app reste cohérent aussi.
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` + Tests Cypress (`apps/web/cypress/e2e/*.cy.ts`) : mockent l'API via `cy.intercept`
`home-planning.cy.ts` + `onboarding.cy.ts` + `household.cy.ts` mockent l'API via plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne pas de
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne Postgres/API, seulement le serveur de dev Vite. Le comportement réel de l'API est
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement couvert par la suite Mocha d'`apps/api` (contre une vraie base).
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une
vraie base).
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** : > **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
> Chromium/Electron headless plante au lancement du process GPU > Chromium/Electron headless plante au lancement du process GPU
@ -425,10 +397,5 @@ en ajouter une est une question de fichier de locale, pas de code. Détail :
## Données de test (faker.js) ## Données de test (faker.js)
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données `apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
de test dans `test/auth.test.ts` (Mocha) et le "bruit" (prénom/nom de remplissage) de test dans `test/*.test.ts` (Mocha) — jamais de nom/email qui ressemble à une
des steps Cucumber — jamais de nom/email qui ressemble à une vraie personne en dur vraie personne en dur dans un fixture.
dans un fixture. Les valeurs *littérales* des scénarios `.feature` eux-mêmes
(ex. `alice@example.com`) restent volontairement statiques : c'est le point des
scénarios Gherkin lisibles (exemples illustratifs conventionnels en BDD, pas des
données réelles) — seules les données de remplissage hors du texte lisible du
scénario sont générées.

View file

@ -1,11 +0,0 @@
// Explicit .cjs extension (not .js) so this loads as CommonJS regardless of
// the "type": "module" in package.json — avoids the same ESM/CJS config
// loader mismatch that broke apps/web/cypress.config.ts earlier.
module.exports = {
default: {
paths: ["features/**/*.feature"],
import: ["features/**/*.ts"],
format: ["progress-bar"],
formatOptions: { snippetInterface: "async-await" },
},
};

View file

@ -1,50 +0,0 @@
Feature: Account creation and login
As a new user
I want to create a profile and log in
So that I can access my household's batch-cooking planning
Scenario: A visitor creates a new profile
When I sign up with the following details:
| firstName | Alice |
| lastName | Martin |
| email | alice@example.com |
| password | correct-horse-battery-staple |
Then the response status should be 201
And I am authenticated as "alice@example.com"
Scenario: A visitor cannot sign up twice with the same email
Given a profile already exists with email "alice@example.com"
When I sign up with the following details:
| firstName | Alice |
| lastName | Martin |
| email | alice@example.com |
| password | correct-horse-battery-staple |
Then the response status should be 409
And the response error code should be "EMAIL_ALREADY_IN_USE"
Scenario: A registered user logs in with correct credentials
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
When I log in with email "alice@example.com" and password "correct-horse-battery-staple"
Then the response status should be 200
And I am authenticated as "alice@example.com"
Scenario: A registered user cannot log in with the wrong password
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
When I log in with email "alice@example.com" and password "wrong-password"
Then the response status should be 401
And the response error code should be "INVALID_CREDENTIALS"
Scenario: A signed-in user cannot delete their account with the wrong password
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I delete my account with password "wrong-password"
Then the response status should be 401
And the response error code should be "INVALID_CREDENTIALS"
And I am authenticated as "alice@example.com"
Scenario: A signed-in user deletes their account
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I delete my account with password "correct-horse-battery-staple"
Then the response status should be 204
And I am no longer authenticated

View file

@ -1,12 +0,0 @@
Feature: API health check
As a monitoring service
I want to query the API
So that I can verify it is up and responding correctly
Scenario: The API is available
When I send a GET request to "/health"
Then the response status should be 200
And the response body should be:
"""
{ "status": "ok" }
"""

View file

@ -1,67 +0,0 @@
Feature: Household
As a signed-in user
I want to name my household, invite others to it, and manage its members
So that my whole household can share the same planning
Scenario: A visitor without a session cannot read the household
When I send a GET request to "/house/current"
Then the response status should be 401
And the response error code should be "NOT_AUTHENTICATED"
Scenario: A signed-in user creates a household
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I create a household named "Chez Alice"
Then the response status should be 201
And my household should be named "Chez Alice"
Scenario: A signed-in user renames their household
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And I have a household named "Foyer de test"
When I rename my household to "Chez les Martin"
Then the response status should be 200
And my household should be named "Chez les Martin"
Scenario: A second user joins a household using its invite code
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And I have a household named "Chez Alice"
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
When the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user joins my household using its invite code
Then the second user's response status should be 200
And the second user should be a member of my household
Scenario: A non-admin member cannot delete the household
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And I have a household named "Chez Alice"
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user joins my household using its invite code
When the second user tries to delete the household
Then the second user's response status should be 403
And the second user's response error code should be "NOT_HOUSE_ADMIN"
Scenario: Adminship transfers to the remaining member when the admin leaves
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And I have a household named "Chez Alice"
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user joins my household using its invite code
When I leave the household
Then the response status should be 204
And the second user should be the household's admin
Scenario: The admin removes a member
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And I have a household named "Chez Alice"
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user joins my household using its invite code
When I remove the second user from my household
Then the response status should be 200
And the second user should have no household

View file

@ -1,24 +0,0 @@
Feature: Household weekly planning
As a signed-in user
I want to see my household's current planning
So that I know what meals are planned this week
Scenario: A visitor without a session cannot view the planning
When I request the current planning
Then the response status should be 401
And the response error code should be "NOT_AUTHENTICATED"
Scenario: A signed-in user with no planning yet sees an empty state
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I request the current planning
Then the response status should be 200
And the current planning response should be empty
Scenario: A signed-in user sees their household's current planning
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And my household has a planning covering today with recipe "Ratatouille" on "monday" for "dinner"
When I request the current planning
Then the response status should be 200
And the current planning response should include recipe "Ratatouille" on "monday" for "dinner"

View file

@ -1,23 +0,0 @@
Feature: User preferences (theme)
As a signed-in user
I want to choose a light, dark, or system theme
So that the app matches how I like to read it
Scenario: A visitor without a session cannot read preferences
When I send a GET request to "/preferences"
Then the response status should be 401
And the response error code should be "NOT_AUTHENTICATED"
Scenario: A signed-in user with no preferences yet defaults to SYSTEM
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I request my preferences
Then the response status should be 200
And my theme preference should be "SYSTEM"
Scenario: A signed-in user sets their theme preference
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I set my theme preference to "DARK"
Then the response status should be 200
And my theme preference should be "DARK"

View file

@ -1,28 +0,0 @@
Feature: Profile regime and allergens
As a signed-in user
I want to set my dietary regime and allergens/intolerances
So that the household's meal planning can account for them later
Scenario: A visitor without a session cannot set a regime
When I send a PATCH request to "/profile/diet" with body:
"""
{ "dietId": 1 }
"""
Then the response status should be 401
And the response error code should be "NOT_AUTHENTICATED"
Scenario: A signed-in user sets their regime to a valid, seeded diet
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I set my regime to "Végétarien"
Then the response status should be 200
And my profile's regime should be "Végétarien"
Scenario: A signed-in user selects allergens, then replaces the selection
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I set my allergens to "Arachides, Gluten"
Then the response status should be 200
And my selected allergens should be "Arachides, Gluten"
When I set my allergens to "Lait"
Then my selected allergens should be "Lait"

View file

@ -1,59 +0,0 @@
Feature: Recipe catalog
As a signed-in user
I want to browse, create and manage recipes
So that the household can plan meals from a shared catalog
Scenario: A visitor without a session cannot browse the catalog
When I request the recipe catalog
Then the response status should be 401
And the response error code should be "NOT_AUTHENTICATED"
Scenario: A signed-in user creates a recipe with an ingredient and a step
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I create a recipe named "Ratatouille" with ingredient "Tomate" and step "Couper les légumes"
Then the response status should be 201
And the created recipe should have ingredient "Tomate" and step "Couper les légumes"
Scenario: Creating a recipe with an unknown ingredient is rejected
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I create a recipe named "Ratatouille" with unknown ingredient id 999999 and step "Couper les légumes"
Then the response status should be 404
And the response error code should be "INGREDIENT_NOT_FOUND"
Scenario: A signed-in user sees their own recipe in the "perso" tab
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
When I request the recipe catalog tab "perso"
Then the response status should be 200
And the recipe catalog response should include "Ratatouille"
Scenario: A signed-in user favorites a recipe and finds it in the "favoris" tab
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
When I favorite the recipe named "Ratatouille"
And I request the recipe catalog tab "favoris"
Then the response status should be 200
And the recipe catalog response should include "Ratatouille"
Scenario: Only a recipe's author can edit it
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And a public recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
When the second user tries to modify the recipe named "Ratatouille"
Then the second user's response status should be 403
And the second user's response error code should be "NOT_RECIPE_AUTHOR"
Scenario: Deleting a recipe still used by a planning item is rejected
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
And my household has a planning that uses the recipe named "Ratatouille"
When I delete the recipe named "Ratatouille"
Then the response status should be 409
And the response error code should be "RECIPE_IN_USE"

View file

@ -1,14 +0,0 @@
Feature: Reference data (diets, allergens)
As a visitor filling in the signup wizard, or a signed-in user editing their profile
I want to read the list of dietary regimes and allergens
So that I can pick from them before an account necessarily exists
Scenario: A visitor without a session can read the list of dietary regimes
When I send a GET request to "/reference/diets"
Then the response status should be 200
And the reference list response should include "Végétarien"
Scenario: A visitor without a session can read the list of allergens
When I send a GET request to "/reference/allergies"
Then the response status should be 200
And the reference list response should include "Arachides"

View file

@ -1,67 +0,0 @@
import assert from "node:assert/strict";
import type { DataTable } from "@cucumber/cucumber";
import { Given, Then, When } from "@cucumber/cucumber";
import { faker } from "@faker-js/faker";
import { signup } from "../../src/modules/auth/auth.service.js";
import type { CustomWorld } from "../support/world.js";
// firstName/lastName/password below are filler for background state the
// scenario doesn't actually read (only the emails in the .feature file are
// part of what's being tested) — faker-generated rather than hardcoded so
// no test fixture ever looks like a real person's data.
Given("a profile already exists with email {string}", async (email: string) => {
await signup({
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email,
password: faker.internet.password({ length: 16 }),
});
});
Given(
"a profile already exists with email {string} and password {string}",
async (email: string, password: string) => {
await signup({
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email,
password,
});
},
);
When("I sign up with the following details:", async function (this: CustomWorld, table: DataTable) {
const details = table.rowsHash();
this.response = await this.agent.post("/auth/signup").send({
firstName: details.firstName,
lastName: details.lastName,
email: details.email,
password: details.password,
});
});
When(
"I log in with email {string} and password {string}",
async function (this: CustomWorld, email: string, password: string) {
this.response = await this.agent.post("/auth/login").send({ email, password });
},
);
When(
"I delete my account with password {string}",
async function (this: CustomWorld, password: string) {
this.response = await this.agent.delete("/auth/me").send({ password });
},
);
Then("I am authenticated as {string}", async function (this: CustomWorld, email: string) {
const res = await this.agent.get("/auth/me");
assert.equal(res.status, 200);
assert.equal(res.body.email, email);
});
Then("I am no longer authenticated", async function (this: CustomWorld) {
const res = await this.agent.get("/auth/me");
assert.equal(res.status, 401);
});

View file

@ -1,38 +0,0 @@
import assert from "node:assert/strict";
import { ErrorCode } from "@batch-cooking/shared";
import { Then, When } from "@cucumber/cucumber";
import request from "supertest";
import type { CustomWorld } from "../support/world.js";
When("I send a GET request to {string}", async function (this: CustomWorld, path: string) {
this.response = await request(this.app).get(path);
});
When(
"I send a PATCH request to {string} with body:",
async function (this: CustomWorld, path: string, body: string) {
this.response = await request(this.app).patch(path).send(JSON.parse(body));
},
);
Then("the response status should be {int}", function (this: CustomWorld, status: number) {
assert.equal(this.response.status, status);
});
Then("the response body should be:", function (this: CustomWorld, expectedJson: string) {
assert.deepEqual(this.response.body, JSON.parse(expectedJson));
});
// Generic enough to be reused by any feature asserting on the shared
// ApiErrorResponse contract's `code` field — not health-specific, but this
// file is where the other generic response-assertion steps already live.
//
// `code` here is the enum *member name* (readable in the .feature file,
// e.g. "EMAIL_ALREADY_IN_USE") — ErrorCode[name] resolves it to the real
// numeric value via TypeScript's reverse enum lookup, so this never
// compares against a hardcoded number.
Then("the response error code should be {string}", function (this: CustomWorld, code: string) {
const expected = ErrorCode[code as keyof typeof ErrorCode];
assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
assert.equal(this.response.body.code, expected);
});

View file

@ -1,90 +0,0 @@
import assert from "node:assert/strict";
import { ErrorCode } from "@batch-cooking/shared";
import { Given, Then, When } from "@cucumber/cucumber";
import type { CustomWorld } from "../support/world.js";
Given("I have a household named {string}", async function (this: CustomWorld, name: string) {
const res = await this.agent.post("/house").send({ name });
assert.equal(res.status, 201, JSON.stringify(res.body));
});
When("I create a household named {string}", async function (this: CustomWorld, name: string) {
this.response = await this.agent.post("/house").send({ name });
});
When("I rename my household to {string}", async function (this: CustomWorld, name: string) {
this.response = await this.agent.patch("/house/current").send({ name });
});
When("I leave the household", async function (this: CustomWorld) {
this.response = await this.agent.post("/house/leave");
});
When("I remove the second user from my household", async function (this: CustomWorld) {
const secondMe = await this.secondAgent.get("/auth/me");
this.response = await this.agent.delete(`/house/members/${secondMe.body.id}`);
});
Then("my household should be named {string}", async function (this: CustomWorld, name: string) {
const res = await this.agent.get("/house/current");
assert.equal(res.body.name, name);
});
// --- Steps involving a second, independently signed-in user ---------------
// The first ("a profile already exists with email ...") step is reused
// as-is for the second user too — it just inserts a row, agent-agnostic.
When(
"the second user logs in with email {string} and password {string}",
async function (this: CustomWorld, email: string, password: string) {
this.secondResponse = await this.secondAgent.post("/auth/login").send({ email, password });
},
);
When(
"the second user joins my household using its invite code",
async function (this: CustomWorld) {
const house = await this.agent.get("/house/current");
this.secondResponse = await this.secondAgent
.post("/house/join")
.send({ inviteCode: house.body.inviteCode });
},
);
When("the second user tries to delete the household", async function (this: CustomWorld) {
this.secondResponse = await this.secondAgent.delete("/house/current");
});
Then(
"the second user's response status should be {int}",
function (this: CustomWorld, status: number) {
assert.equal(this.secondResponse.status, status);
},
);
Then(
"the second user's response error code should be {string}",
function (this: CustomWorld, code: string) {
const expected = ErrorCode[code as keyof typeof ErrorCode];
assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`);
assert.equal(this.secondResponse.body.code, expected);
},
);
Then("the second user should be a member of my household", async function (this: CustomWorld) {
const house = await this.agent.get("/house/current");
const secondMe = await this.secondAgent.get("/auth/me");
const memberIds = (house.body.members as Array<{ id: number }>).map((member) => member.id);
assert.ok(memberIds.includes(secondMe.body.id));
});
Then("the second user should be the household's admin", async function (this: CustomWorld) {
const secondMe = await this.secondAgent.get("/auth/me");
const house = await this.secondAgent.get("/house/current");
assert.equal(house.body.adminId, secondMe.body.id);
});
Then("the second user should have no household", async function (this: CustomWorld) {
const secondMe = await this.secondAgent.get("/auth/me");
assert.equal(secondMe.body.houseId, null);
});

View file

@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import { Given, Then, When } from "@cucumber/cucumber";
import { prisma } from "../../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
import type { CustomWorld } from "../support/world.js";
/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to the fixed test "today" (see `TEST_REFERENCE_DATE`). */
When("I request the current planning", async function (this: CustomWorld) {
this.response = await this.agent
.get("/planning")
.query({ date: TEST_REFERENCE_DATE.toISODate() });
});
Then("the current planning response should be empty", function (this: CustomWorld) {
assert.equal(this.response.body, null);
});
// Creates the planning/recipe rows directly via Prisma rather than through
// the API — there's no "create a planning" endpoint yet (see
// specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still
// TODO), so this is the only way to get a household into a state where it
// has one. A household is no longer created implicitly at signup, so this
// step creates one via `POST /house` first — the scenario never names it
// explicitly, its name doesn't matter here.
Given(
"my household has a planning covering today with recipe {string} on {string} for {string}",
async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({
data: { name: recipeName, authorId: houseRes.body.adminId },
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.create({
data: { planningId: planning.id, weekDay, meal, recipeId: recipe.id },
});
},
);
Then(
"the current planning response should include recipe {string} on {string} for {string}",
function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
const items = this.response.body.items as Array<{
weekDay: string;
meal: string;
recipe: { name: string };
}>;
const item = items.find((i) => i.recipe.name === recipeName);
assert.ok(item, `expected an item with recipe "${recipeName}", got ${JSON.stringify(items)}`);
assert.equal(item.weekDay, weekDay);
assert.equal(item.meal, meal);
},
);

View file

@ -1,15 +0,0 @@
import assert from "node:assert/strict";
import { Then, When } from "@cucumber/cucumber";
import type { CustomWorld } from "../support/world.js";
When("I request my preferences", async function (this: CustomWorld) {
this.response = await this.agent.get("/preferences");
});
When("I set my theme preference to {string}", async function (this: CustomWorld, theme: string) {
this.response = await this.agent.patch("/preferences").send({ theme });
});
Then("my theme preference should be {string}", function (this: CustomWorld, theme: string) {
assert.equal(this.response.body.theme, theme);
});

View file

@ -1,54 +0,0 @@
import assert from "node:assert/strict";
import { Then, When } from "@cucumber/cucumber";
import { getEnglishKey } from "../../src/db/catalog-en-keys.js";
import { prisma } from "../../src/db/prisma.js";
import type { CustomWorld } from "../support/world.js";
/** Splits a comma-separated list of names from a `.feature` string into trimmed, non-empty parts. */
function splitNames(names: string): string[] {
return names
.split(",")
.map((name) => name.trim())
.filter(Boolean);
}
/**
* Resolves allergen names (as written in a `.feature` file, e.g.
* "Arachides") to their Allergy id scenarios still name allergens by their
* French label for readability, so this slugifies before matching against
* `Category.key` (see reference.service.ts for why the key lives on
* Category, not Allergy).
*/
async function allergyIdsFor(names: string[]): Promise<number[]> {
const allergies = await prisma.allergy.findMany({ include: { category: true } });
return names.map((name) => {
const key = getEnglishKey(name);
const match = allergies.find((allergy) => allergy.category.key === key);
if (!match) throw new Error(`No seeded allergen named "${name}"`);
return match.id;
});
}
When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) {
const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey(dietName) } });
this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id });
});
Then(
"my profile's regime should be {string}",
async function (this: CustomWorld, dietName: string) {
const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey(dietName) } });
assert.equal(this.response.body.dietId, diet.id);
},
);
When("I set my allergens to {string}", async function (this: CustomWorld, names: string) {
const allergyIds = await allergyIdsFor(splitNames(names));
this.response = await this.agent.patch("/profile/allergies").send({ allergyIds });
});
Then("my selected allergens should be {string}", async function (this: CustomWorld, names: string) {
const expected = (await allergyIdsFor(splitNames(names))).sort();
const actual = [...this.response.body].sort();
assert.deepEqual(actual, expected);
});

View file

@ -1,168 +0,0 @@
import assert from "node:assert/strict";
import { Given, Then, When } from "@cucumber/cucumber";
import { getEnglishKey } from "../../src/db/catalog-en-keys.js";
import { prisma } from "../../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
import type { CustomWorld } from "../support/world.js";
/**
* Resolves a reference ingredient by its seeded French name every
* scenario below names an ingredient by its `reference-seed-data.ts` name,
* never a raw id or its slug `key` directly, so this slugifies before
* matching.
*/
async function findIngredientId(name: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({
where: { key: getEnglishKey(name) },
});
return ingredient.id;
}
When("I request the recipe catalog", async function (this: CustomWorld) {
this.response = await this.agent.get("/recipes");
});
When("I request the recipe catalog tab {string}", async function (this: CustomWorld, tab: string) {
this.response = await this.agent.get("/recipes").query({ tab });
});
Then(
"the recipe catalog response should include {string}",
function (this: CustomWorld, name: string) {
const names = (this.response.body as Array<{ name: string }>).map((recipe) => recipe.name);
assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`);
},
);
When(
"I create a recipe named {string} with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
this.response = await this.agent.post("/recipes").send({
name,
dietIds: [],
ingredients: [{ ingredientId, quantity: 1, unit: "unité" }],
steps: [{ description: step }],
});
},
);
When(
"I create a recipe named {string} with unknown ingredient id {int} and step {string}",
async function (this: CustomWorld, name: string, unknownIngredientId: number, step: string) {
this.response = await this.agent.post("/recipes").send({
name,
dietIds: [],
ingredients: [{ ingredientId: unknownIngredientId, quantity: 1, unit: "unité" }],
steps: [{ description: step }],
});
},
);
Then(
"the created recipe should have ingredient {string} and step {string}",
function (this: CustomWorld, ingredientName: string, step: string) {
const body = this.response.body as {
ingredients: Array<{ ingredient: { key: string } }>;
steps: Array<{ description: string }>;
};
const expectedKey = getEnglishKey(ingredientName);
assert.ok(body.ingredients.some((line) => line.ingredient.key === expectedKey));
assert.ok(body.steps.some((s) => s.description === step));
},
);
// Created directly via Prisma (with a nested ingredient + step), not through
// the API — same rationale as `planning.steps.ts`'s equivalent "already
// exists" step: this is background state the scenario needs in place before
// its actual `When`, not the behavior under test. `authorId` is the
// currently-logged-in agent's own profile — `visibility` defaults to
// `PERSONAL` (schema.prisma), matching a recipe this agent just created for
// themselves.
Given(
"a recipe named {string} already exists with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
const me = await this.agent.get("/auth/me");
await prisma.recipe.create({
data: {
name,
authorId: me.body.id,
ingredients: { create: [{ ingredientId, quantity: 1, unit: "unité" }] },
steps: { create: [{ description: step, order: 0 }] },
},
});
},
);
// Same as above but `visibility: PUBLIC` — needed for scenarios where a
// *second* user must be able to see (though not necessarily edit) the
// recipe, e.g. the "only the author can edit" scenario: a `PERSONAL`
// recipe would 404 for anyone else before the authorship check even runs
// (see `recipe.service.ts`'s `canView`).
Given(
"a public recipe named {string} already exists with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
const me = await this.agent.get("/auth/me");
await prisma.recipe.create({
data: {
name,
authorId: me.body.id,
visibility: "PUBLIC",
ingredients: { create: [{ ingredientId, quantity: 1, unit: "unité" }] },
steps: { create: [{ description: step, order: 0 }] },
},
});
},
);
// Distinct from `planning.steps.ts`'s "my household has a planning covering
// today with recipe {string}..." — that step always creates a *new* recipe
// row with the given name, which wouldn't exercise the actual `RECIPE_IN_USE`
// check against a recipe this feature already created. This step instead
// looks up the already-existing recipe by name and points the planning item
// at its real id.
Given(
"my household has a planning that uses the recipe named {string}",
async function (this: CustomWorld, recipeName: string) {
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name: recipeName } });
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
});
},
);
When("I delete the recipe named {string}", async function (this: CustomWorld, name: string) {
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.response = await this.agent.delete(`/recipes/${recipe.id}`);
});
When("I favorite the recipe named {string}", async function (this: CustomWorld, name: string) {
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.response = await this.agent.post(`/recipes/${recipe.id}/favorite`);
});
When(
"the second user tries to modify the recipe named {string}",
async function (this: CustomWorld, name: string) {
const ingredientId = await findIngredientId("Tomate");
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.secondResponse = await this.secondAgent.patch(`/recipes/${recipe.id}`).send({
name,
dietIds: [],
ingredients: [{ ingredientId, quantity: 1, unit: "unité" }],
steps: [{ description: "Hack" }],
});
},
);

View file

@ -1,20 +0,0 @@
import assert from "node:assert/strict";
import { Then } from "@cucumber/cucumber";
import { getEnglishKey } from "../../src/db/catalog-en-keys.js";
import type { CustomWorld } from "../support/world.js";
// The feature file still names an item by its French label, for
// readability — the response itself carries only the slug `key` (see
// reference.service.ts), so this slugifies the expected label before
// comparing.
Then(
"the reference list response should include {string}",
function (this: CustomWorld, name: string) {
const keys = (this.response.body as Array<{ key: string }>).map((item) => item.key);
const expectedKey = getEnglishKey(name);
assert.ok(
keys.includes(expectedKey),
`expected ${JSON.stringify(keys)} to include "${expectedKey}"`,
);
},
);

View file

@ -1,10 +0,0 @@
import { Before, setDefaultTimeout } from "@cucumber/cucumber";
import { resetDatabase } from "../../test-support/reset-db.js";
// Integration tests hitting a real Postgres + argon2 need more than
// Cucumber's 5s default, especially on a cold Prisma connection.
setDefaultTimeout(10_000);
Before(async () => {
await resetDatabase();
});

View file

@ -1,26 +0,0 @@
import { type IWorldOptions, World, setWorldConstructor } from "@cucumber/cucumber";
import type { Express } from "express";
import request from "supertest";
import { createApp } from "../../src/app.js";
// Fresh Express app per scenario (in-process, via supertest — no server to
// spin up/tear down) plus the last HTTP response, available to every step.
// `agent` persists cookies across requests within a scenario (needed for
// "sign up then check I'm authenticated" style flows).
export class CustomWorld extends World {
app: Express;
agent: ReturnType<typeof request.agent>;
response!: request.Response;
/** A second, independent session (own cookie jar) — only used by scenarios needing two distinct signed-in users, e.g. household invites/admin transfer/member removal. */
secondAgent: ReturnType<typeof request.agent>;
secondResponse!: request.Response;
constructor(options: IWorldOptions) {
super(options);
this.app = createApp();
this.agent = request.agent(this.app);
this.secondAgent = request.agent(this.app);
}
}
setWorldConstructor(CustomWorld);

View file

@ -8,7 +8,6 @@
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "node dist/server.js", "start": "node dist/server.js",
"test": "cross-env NODE_ENV=test mocha", "test": "cross-env NODE_ENV=test mocha",
"test:bdd": "cross-env NODE_ENV=test NODE_OPTIONS=--import=tsx cucumber-js",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:seed": "prisma db seed", "prisma:seed": "prisma db seed",
@ -31,7 +30,6 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@cucumber/cucumber": "^13.2.1",
"@faker-js/faker": "^10.6.0", "@faker-js/faker": "^10.6.0",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",

File diff suppressed because it is too large Load diff