batchCooking/apps/web/cypress/e2e/recipe-form.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

105 lines
3.1 KiB
TypeScript

import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
const tomato = {
id: 1,
key: "tomato",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [{ id: 2, key: "vegetarian" }],
};
const egg = {
id: 2,
key: "egg",
icon: "EGG",
category: "dairyAndCheese",
subcategory: "eggs",
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
diets: [],
};
const carrot = {
id: 3,
key: "carrot",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [{ id: 2, key: "vegetarian" }],
};
const diets = [
{ id: 1, key: "omnivore" },
{ id: 2, key: "vegetarian" },
];
const pieceUnit = { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 };
const gramUnit = { id: 2, key: "gram", type: "MASS", toBaseFactor: 1 };
Given("the ingredient and diet catalog is available", () => {
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] });
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets });
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [pieceUnit, gramUnit] });
});
Given("creating the recipe will succeed and return id {int}", (id: number) => {
cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id } }).as("createRecipe");
});
When("I visit the new recipe form without a secure random UUID", () => {
cy.visit("/recettes/nouvelle", {
onBeforeLoad(win) {
Object.defineProperty(win.crypto, "randomUUID", {
value: undefined,
configurable: true,
});
},
});
});
Then(
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}",
(name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => {
cy.wait("@createRecipe")
.its("request.body")
.should("deep.include", {
name,
portions,
ingredients: [{ ingredientId, quantity, unitId }],
});
},
);
Given("recipe 7 exists with an egg omelette", () => {
const existingRecipe = {
id: 7,
name: "Omelette",
description: null,
picture: null,
portions: 2,
authorId: 1,
visibility: "PERSONAL",
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
diets: [],
isFavorite: false,
ingredients: [{ ingredient: egg, quantity: 3, unit: pieceUnit }],
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }],
};
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
});
Given("updating recipe 7 will succeed", () => {
cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe");
});
Then(
"the recipe update request should have included these ingredients:",
(dataTable: DataTable) => {
const expected = dataTable.hashes().map((row) => ({
ingredientId: Number(row.ingredientId),
quantity: Number(row.quantity),
unitId: Number(row.unitId),
}));
cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected);
},
);