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).
217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
||
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
|
||
// behavior against a real database.
|
||
//
|
||
// The favorite-toggle and delete-recipe journeys moved to recipes.feature —
|
||
// this file now only covers catalog browsing/display.
|
||
|
||
const authenticatedProfile = {
|
||
id: 1,
|
||
firstName: "Alice",
|
||
lastName: "Martin",
|
||
email: "alice@example.com",
|
||
tokenVersion: 0,
|
||
houseId: 1,
|
||
dietId: null,
|
||
};
|
||
|
||
const vegetarien = { id: 1, key: "vegetarian" };
|
||
const gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" };
|
||
const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" };
|
||
|
||
const ratatouille = {
|
||
id: 1,
|
||
name: "Ratatouille",
|
||
description: null,
|
||
picture: null,
|
||
authorId: 1,
|
||
visibility: "PERSONAL",
|
||
allergens: [],
|
||
diets: [vegetarien],
|
||
isFavorite: true,
|
||
};
|
||
|
||
const omelette = {
|
||
id: 2,
|
||
name: "Omelette",
|
||
description: null,
|
||
picture: null,
|
||
authorId: 1,
|
||
visibility: "PERSONAL",
|
||
allergens: [oeufs],
|
||
diets: [],
|
||
isFavorite: false,
|
||
};
|
||
|
||
const omeletteDetail = {
|
||
...omelette,
|
||
description: "Une omelette toute simple.",
|
||
ingredients: [
|
||
{
|
||
ingredient: {
|
||
id: 10,
|
||
key: "egg",
|
||
icon: "EGG",
|
||
category: "CREMERIE_FROMAGE",
|
||
subcategory: "OEUFS",
|
||
allergens: [oeufs],
|
||
diets: [],
|
||
},
|
||
quantity: 3,
|
||
unit: "unité",
|
||
},
|
||
],
|
||
steps: [
|
||
{ id: 1, description: "Battre les œufs.", picture: null, order: 1 },
|
||
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2 },
|
||
],
|
||
};
|
||
|
||
function interceptAuth() {
|
||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
||
}
|
||
|
||
describe("Recipe catalog", () => {
|
||
beforeEach(() => {
|
||
interceptAuth();
|
||
});
|
||
|
||
it("defaults to the Favoris tab and lists its recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
expect(req.url).to.include("tab=favoris");
|
||
req.reply({ statusCode: 200, body: [ratatouille] });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
|
||
cy.contains("h1", "Recettes").should("be.visible");
|
||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Favoris");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
// The favorited row carries the ★ fav-mark.
|
||
cy.contains(".recipe-table__name", "Ratatouille")
|
||
.find(".recipe-table__fav-mark")
|
||
.should("exist");
|
||
cy.contains(".recipe-table__name", "Ratatouille")
|
||
.parents("tr")
|
||
.find(".diet-badge")
|
||
.should("contain.text", "Végétarien");
|
||
});
|
||
|
||
it("shows the empty state when a tab has no recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains("Aucune recette pour le moment.").should("be.visible");
|
||
});
|
||
|
||
it("shows an error state when the catalog fails to load", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains(".recipes-page__status--error", "Impossible de charger").should("be.visible");
|
||
});
|
||
|
||
it("switches tabs, re-fetching each one's own recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
const tab = new URL(req.url).searchParams.get("tab");
|
||
const body = tab === "perso" ? [omelette] : [ratatouille];
|
||
req.reply({ statusCode: 200, body });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
|
||
// Not asserting the specific request URL here — React StrictMode (see
|
||
// main.tsx) double-invokes mount/update effects in dev, so this can
|
||
// legitimately fire twice; the rendered result converges either way
|
||
// (same reasoning as planning-page.cy.ts's week-navigation tests).
|
||
cy.contains(".recipe-tabs__tab", "Perso").click();
|
||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
|
||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
||
|
||
// The disabled "Sources (bientôt)" placeholder never becomes active.
|
||
cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled");
|
||
});
|
||
|
||
it("searches within the active tab, debounced", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
const search = new URL(req.url).searchParams.get("search");
|
||
req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
|
||
// Same "assert the rendered result, not the request count/URL" reasoning
|
||
// as the tab-switch test above — the 300ms debounce plus StrictMode's
|
||
// double-invoked effects make the exact number/order of requests an
|
||
// implementation detail, not something worth pinning down here.
|
||
cy.get(".recipes-page__search").type("Omel");
|
||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
||
});
|
||
|
||
it("opens a recipe's detail alongside the table when its row is selected", () => {
|
||
// Desktop-only master-detail layout, same reasoning as
|
||
// planning-page.cy.ts's "Planning grid" tests — wider/taller than
|
||
// Cypress's default 1000×660, which doesn't leave the detail panel
|
||
// (photo + header + ingredients + steps) enough height to show
|
||
// everything without needing its own internal scroll (by design, see
|
||
// `.recipe-detail-panel` in recipes.scss) — this test asserts full
|
||
// visibility without scrolling, so it needs the room.
|
||
cy.viewport(1600, 900);
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
|
||
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe");
|
||
|
||
cy.visit("/recettes");
|
||
cy.contains(".recipe-table__name", "Omelette").click();
|
||
cy.wait("@getRecipe");
|
||
|
||
cy.url().should("include", "/recettes/2");
|
||
// The table stays mounted (master-detail, not a page navigation) —
|
||
// both rows are still visible next to the detail panel.
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
cy.get("tr.selected .recipe-table__name").should("contain.text", "Omelette");
|
||
|
||
cy.get(".recipe-detail-panel").within(() => {
|
||
cy.contains("h2", "Omelette").should("be.visible");
|
||
cy.contains("Une omelette toute simple.").should("be.visible");
|
||
cy.contains("Battre les œufs.").should("be.visible");
|
||
cy.contains("Cuire à la poêle.").should("be.visible");
|
||
});
|
||
});
|
||
|
||
it("shows a not-found message for a selected id the API rejects", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
cy.intercept("GET", "**/recipes/999", {
|
||
statusCode: 404,
|
||
// ErrorCode.RECIPE_NOT_FOUND (packages/shared/src/errors/error-codes.ts)
|
||
// — RecipeDetailPanel only renders the "not found" message for this
|
||
// exact code, anything else falls into its generic error state.
|
||
body: { code: 4045, message: "not found" },
|
||
});
|
||
|
||
cy.visit("/recettes/999");
|
||
|
||
cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible");
|
||
});
|
||
|
||
it("links the new-recipe button to the recipe form", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains(".recipes-page__new-button", "Nouvelle recette").should(
|
||
"have.attr",
|
||
"href",
|
||
"/recettes/nouvelle",
|
||
);
|
||
});
|
||
});
|