batchCooking/apps/web/cypress/e2e/recipes.cy.ts
Nicolas 37a044a267 chore(lint): upgrade Biome vers 2.x, active noExplicitAny/noConsole/noFloatingPromises
`@biomejs/biome` passe de 1.9.4 à 2.5.9 (config migrée via `biome migrate
--write`) — nécessaire pour noFloatingPromises, une règle type-aware
apparue en 2.0 (nursery).

- noExplicitAny : déjà "recommended", actif depuis toujours, aucun changement.
- noConsole (biome.json) : bloque tout `console.*` sauf error/warn/info/
  debug/table/assert — équivalent à "pas de console.log" sans interdire
  les niveaux nommés (voir le nouveau log service dans le prochain commit,
  qui centralise justement ces appels).
- noFloatingPromises (nursery) activé explicitement sous `rules.nursery`
  sans avoir besoin d'activer le domaine "types" au sens large (ça aurait
  aussi allumé des dizaines d'autres règles type-aware type
  noUnresolvedImports/noUnnecessaryConditions, hors scope ici).

Le reste du diff, c'est soit du reformatage automatique (import sort, 2.x
ordonne différemment de 1.9.4 — `biome check --write --unsafe`), soit les
corrections des ~20 promesses flottantes que la nouvelle règle a fait
remonter :

- La plupart sont des `navigate(...)` non attendus (react-router v7 type
  `navigate` en `void | Promise<void>`) — préfixés `void navigate(...)`,
  aucun changement de comportement.
- Trois chargements initiaux en useEffect (OnboardingAllergensPage,
  OnboardingDietPage, OnboardingHouseholdPage, HouseholdSettingsPage)
  n'avaient jamais de `.catch()` du tout — ajouté (dégradation silencieuse
  vers un état vide/par défaut, même raisonnement que le `.catch()` déjà
  présent dans OnboardingSourcesPage).
- HouseholdSettingsPage : `loadHouse` était une fonction déclarée à chaque
  render (donc une référence différente à chaque fois) utilisée comme
  dépendance de useEffect ET passée en callback à des enfants — le
  useEffect se re-déclenchait donc à chaque re-render provoqué par son
  propre fetch, un vrai bug de boucle infinie de requêtes que
  noFloatingPromises a fait remonter indirectement (via
  useExhaustiveDependencies). Corrigé avec useCallback([]).
- RecipeDetailPanel : une clé de liste `${index}-...}` sur une liste
  statique (draft.steps, sans id stable — DraftRecipeStepView n'en a pas)
  — biome-ignore justifié, pas de bug réel.
- recipe.test.ts : variable `agent` non utilisée, retirée.

Vérifié : `pnpm --filter api test` (295/295), `pnpm lint` et `pnpm build`
clean sur tout le repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:11:57 +02:00

216 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
portions: 4,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [vegetarien],
isFavorite: true,
};
const omelette = {
id: 2,
name: "Omelette",
description: null,
picture: null,
portions: 2,
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: "dairyAndCheese",
subcategory: "eggs",
allergens: [oeufs],
diets: [],
},
quantity: 3,
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
steps: [
{ id: 1, description: "Battre les œufs.", picture: null, order: 1, techSteps: [] },
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2, techSteps: [] },
],
};
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");
});
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",
);
});
});