batchCooking/apps/web/cypress/e2e/planning.ts
Nicolas 991f91bc0e feat(planning): ajouter au planning déclenche l'import si besoin (étape 4/4)
Dernière étape du plan « onglet Sources » : le sélecteur de recette du
planning (`RecipePickerDialog`) gagne l'onglet « Sources », jusqu'ici
volontairement exclu faute d'écran de revue à qui transmettre un item
choisi (voir étape 3, #47).

- Sélectionner un item déjà importé se comporte exactement comme
  choisir cette même recette depuis un onglet normal (résolue via
  `GET /recipes/:id`, direction vers l'étape « combien de portions ? »
  du dialogue, sans navigation).
- Sélectionner un item pas encore importé bascule vers l'écran de
  revue existant (`ImportRecipePage`), avec le créneau du planning
  porté par la query string (`?planningDate=&planningWeekDay=&planningMeal=`).
  Un import réussi y ajoute alors automatiquement la recette
  fraîchement créée à ce créneau (`POST /planning/items`, avec les
  portions du formulaire) avant de revenir sur le planning — plutôt que
  d'atterrir sur la page de la recette comme le fait un import « classique ».
- `RecipeSourcesPanel`/`SourceItemPreviewPanel` généralisés en
  conséquence : la première ne navigue plus elle-même vers la recette
  déjà importée (`onSelectImportedRecipe` renvoie l'id, chaque appelant
  décide), la seconde propage le créneau optionnel sur son lien
  d'import.

Aucun changement backend : `POST /sources/:sourceKey/import/:externalId`
et `POST /planning/items` existaient déjà et suffisent tels quels — une
fois la recette importée, `GET /sources/:sourceKey/browse` la marque
déjà `alreadyImported` automatiquement (logique déjà couverte par
`sources.test.ts`). 282 tests API toujours au vert, aucune régression.

Tests :
- Cypress : nouveau scénario Gherkin bout-en-bout
  (`cypress/e2e/planning.feature`/`planning.ts`) — ouvrir le
  sélecteur depuis un créneau vide, parcourir Sources, importer un
  item non résolu (ingrédient à compléter compris), vérifier que la
  requête d'ajout au planning porte bien le bon créneau/les bonnes
  portions, que la recette apparaît dans la bonne case de la grille
  après le retour sur "/", puis que rebrowser la source la marque
  désormais comme déjà importée.

Suite : plan « onglet Sources » terminé (étapes 1 à 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:42:43 +02:00

231 lines
7.7 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.

import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// 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 (see test/sources.test.ts,
// test/planning.test.ts).
//
// planning.feature's journey crosses both `RecipePickerDialog` (browsing an
// external source from a planning slot) and the import review screen
// (`ImportRecipePage`) it hands off to — same "each spec's own
// self-contained fixtures" precedent recipe-sources.ts already sets (the
// Cucumber preprocessor's step lookup isn't global across cypress/e2e/, see
// its own comment for the full reasoning), so most of what's below mirrors
// recipe-sources.ts's fixtures rather than importing them.
// Both flip once, from `false` to `true`, as the single scenario in this
// file actually performs the import and the planning-add — module-level
// `let` rather than something reset per-scenario, since there's only ever
// the one here (see household-settings.ts for the same pattern used across
// several scenarios instead).
let fishPieImported = false;
let fishPiePlanned = false;
Given("the recipe catalog contains nothing", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
});
Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
});
// Stateful — "Fish Pie" starts out not imported, and flips the moment
// "importing the previewed item will succeed ..." below actually fires, so
// re-browsing after the import journey completes reflects it without a page
// reload (see this feature's closing assertions).
Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
req.reply({
statusCode: 200,
body: {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: fishPieImported,
recipeId: fishPieImported ? 99 : null,
},
],
nextCursor: null,
},
});
});
});
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,
body: {
sourceKey: "theMealDb",
externalId,
name: "Fish Pie",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://www.themealdb.com/meal/9999",
ingredients: [
{
rawText: "1 onion",
quantity: 1,
ingredient: {
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
},
unit: null,
},
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
],
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
},
});
});
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
// units, for the review form) fetch — same endpoints, one fixture for both.
Given("the ingredient and diet catalog is available for import", () => {
cy.intercept("GET", "**/reference/ingredients", {
statusCode: 200,
body: [
{
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [],
},
{
id: 2,
key: "salt",
icon: "SPICE",
category: "condimentsAndSpices",
subcategory: "spices",
allergens: [],
diets: [],
},
],
});
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [{ id: 1, key: "omnivore" }],
});
cy.intercept("GET", "**/reference/units", {
statusCode: 200,
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
});
});
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
cy.intercept("POST", "**/sources/theMealDb/import/9999", (req) => {
fishPieImported = true;
req.reply({ statusCode: 201, body: { id } });
}).as("importRecipe");
});
Given("adding the imported recipe to the planning will succeed", () => {
cy.intercept("POST", "**/planning/items", (req) => {
fishPiePlanned = true;
req.reply({
statusCode: 201,
body: {
id: 1,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
});
}).as("addPlanningItem");
});
// Stateful for the same reason as "browsing TheMealDB returns some items"
// above — landing back on "/" after the import journey remounts
// `PlanningPage` from scratch (a real cross-route navigation, not a
// same-component state update: see `ImportRecipePage`'s `navigate("/")`),
// so only a fresh `GET /planning?date=` that reflects the just-added item
// makes it show up there — nothing client-side survives that remount to
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems`
// does for an add made without leaving the page.
Given("the planning request reflects whatever's been added so far", () => {
cy.intercept("GET", /\/planning\?/, (req) => {
req.reply({
statusCode: 200,
body: fishPiePlanned
? {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
],
}
: null,
});
});
});
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
// first entry × `WEEK_DAYS`'s first entry, see `PlanningGrid`) — the exact
// slot this feature's fixtures above (weekDay "lundi", meal
// "petit-dejeuner") are written against.
When("I click the add button for the first empty planning slot", () => {
cy.get(".add-recipe-btn").first().click();
});
When("I click the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).click();
});
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
cy.contains(".import-recipe__unresolved-row", rawText)
.contains("button", "Choisir un ingrédient")
.click();
});
Then("the source item {string} should be marked as already imported", (title: string) => {
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
});
Then(
"the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}",
(recipeId: number, weekDay: string, meal: string, portions: number) => {
cy.wait("@addPlanningItem")
.its("request.body")
.should("deep.include", { recipeId, weekDay, meal, portions });
},
);
Then(
"the recipe {string} should appear in the first planning slot with {int} portions",
(name: string, portions: number) => {
cy.get(".planning-grid tbody tr")
.first()
.within(() => {
cy.contains(".recipe-chip", `${name} · ×${portions}`).should("be.visible");
});
},
);