batchCooking/apps/web/cypress/support/step_definitions/common.steps.ts
Nicolas e4c8d910d4 feat(recipes): écran de revue et finalisation de l'import (étape 3/4)
Backend :
- `createRecipe` refactorisé en fine enveloppe autour d'un nouvel
  helper interne `createRecipeInternal`, paramétré par une source
  d'import optionnelle ; nouvelle fonction exportée
  `createImportedRecipe` qui réutilise toute la validation
  ingrédients/unités/diets et le matching des tech steps, sans
  dupliquer cette logique.
- La locale de l'adaptateur source est propagée jusqu'au chargement
  des `TechStepMapping`, pour que le texte anglais (TheMealDB, etc.)
  soit matché contre le bon jeu de règles au lieu du défaut français.
- Nouvel endpoint `POST /sources/:sourceKey/import/:externalId` —
  valide le payload via `createRecipeSchema` (même schéma qu'une
  création manuelle) et persiste une vraie `Recipe` liée à la source
  (`sourceId`/`externalId`).
- Nouveau code d'erreur `RECIPE_ALREADY_IMPORTED` (4022) quand
  l'item a déjà été importé pour ce foyer.

Frontend :
- `ImportRecipePage` (nouvelle page, `/recettes/importer/:sourceKey/:externalId`) —
  pré-remplit le formulaire depuis `previewSourceItem`, en miroir de
  `RecipeFormPage` (mêmes sous-composants : `IngredientRow`,
  `IngredientPicker`, `StepListEditor`, `DietTagSelect`). Ajoute une
  section dédiée aux lignes d'ingrédients non résolues automatiquement :
  l'utilisateur choisit un ingrédient réel via l'`IngredientPicker`
  existant ou retire la ligne — aucune recette invalide n'est jamais
  soumise, le bouton d'import reste désactivé tant qu'il en reste.
- `SourceItemPreviewPanel` gagne un lien « Importer cette recette »
  vers cet écran.

Tests :
- Mocha (`apps/api/test/sources.test.ts`) : 6 nouveaux tests sur
  `POST /sources/:sourceKey/import/:externalId` (payload valide,
  ingrédient/unité inconnus, déjà importé, deux foyers distincts,
  locale de la source respectée pour les tech steps). 282 tests
  passent au total, aucune régression.
- Cypress : nouveau scénario Gherkin bout-en-bout dans
  `recipe-sources.feature` (parcourir → prévisualiser → importer →
  résoudre un ingrédient non reconnu → confirmer → atterrir sur la
  recette sauvegardée). Steps d'édition d'ingrédients/étapes
  génériques déplacés de `recipe-form.ts` vers
  `cypress/support/step_definitions/common.steps.ts`, réutilisables
  par ce nouveau scénario.

Suite : étape 4 (ajouter au planning déclenche l'import si nécessaire).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:56:06 +02:00

293 lines
11 KiB
TypeScript

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");
});
// Needed for a section that can render below the fold of `.app-content`'s
// own scroll (see layout.cy.ts) — a bare `.should("be.visible")` doesn't
// auto-scroll (only interaction commands like `.click()`/`.check()` do),
// so a section low on a long page needs this before asserting on it.
When("I scroll to the section {string}", (legend: string) => {
cy.contains("legend", legend).scrollIntoView();
});
// `.recipe-detail-panel` is used by both a saved recipe's real detail
// (RecipeDetailPanel) and an unsaved source item's read-only preview
// (SourceItemPreviewPanel) — recipes.feature and recipe-sources.feature
// both need this.
Then("the recipe detail panel heading should be {string}", (text: string) => {
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
});
// `.step-tech-step`/`.tooltip__bubble` come from StepDescription/Tooltip
// (components/ui/), rendered by both of those same two panels — same
// reasoning as the detail-panel-heading step above.
Then("I should see the highlighted technique {string}", (text: string) => {
// The steps section can sit below the panel's header/photo/description,
// off the fold of `.app-content`'s own scroll (see layout.cy.ts) — a
// bare `.should("be.visible")` doesn't auto-scroll.
cy.contains(".step-tech-step", text).scrollIntoView().should("be.visible");
});
When("I focus the highlighted technique {string}", (text: string) => {
cy.contains(".step-tech-step", text).focus();
});
Then("the tooltip should show {string}", (label: string) => {
cy.get(".tooltip__bubble").contains(label).should("be.visible");
});
// `IngredientPicker`/`IngredientRow`/`StepListEditor` (features/recipes/)
// back both RecipeFormPage and ImportRecipePage — recipe-form.feature and
// import-recipe.feature both need these.
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").select(unit);
},
);
Then("the ingredient's quantity should be {string}", (quantity: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity);
});
When("I select unit {string} for the first ingredient", (unit: string) => {
cy.get(".ingredient-row .ingredient-row__unit").first().select(unit);
});
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().select(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 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);
});