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>
200 lines
6.2 KiB
TypeScript
200 lines
6.2 KiB
TypeScript
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).
|
|
//
|
|
// "the sources reference list has options"/"the household's enabled
|
|
// sources are empty" resolve from cypress/support/step_definitions/ (the
|
|
// preprocessor's step-lookup is *not* global across cypress/e2e/ — only a
|
|
// feature's own same-named file/directory plus that shared folder are
|
|
// searched, see its error message when a step isn't found). recipes.ts
|
|
// sits directly in cypress/e2e/ (not that shared folder), so its own
|
|
// "the disliked ingredients list is empty"/"the recipe catalog
|
|
// contains"/"recipe 2's detail is available" are scoped to recipes.feature
|
|
// only — this file redeclares its own minimal equivalents rather than
|
|
// relocating shared infra, the same "each spec's own self-contained
|
|
// fixtures" precedent recipes.cy.ts already sets alongside recipes.ts.
|
|
|
|
Given("the disliked ingredients list is empty", () => {
|
|
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
|
});
|
|
|
|
Given("the recipe catalog contains nothing", () => {
|
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
|
});
|
|
|
|
Given("recipe 2's detail is available", () => {
|
|
cy.intercept("GET", "**/recipes/2", {
|
|
statusCode: 200,
|
|
body: {
|
|
id: 2,
|
|
name: "Omelette",
|
|
description: null,
|
|
picture: null,
|
|
portions: 2,
|
|
authorId: 1,
|
|
visibility: "PERSONAL",
|
|
allergens: [],
|
|
diets: [],
|
|
isFavorite: false,
|
|
ingredients: [],
|
|
steps: [
|
|
{
|
|
id: 1,
|
|
description: "Cuire à la poêle.",
|
|
picture: null,
|
|
order: 1,
|
|
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
Given("the household has enabled TheMealDB", () => {
|
|
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
|
});
|
|
|
|
Given("browsing TheMealDB returns some items", () => {
|
|
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
|
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: false,
|
|
recipeId: 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: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
Then("I should see the source item {string}", (title: string) => {
|
|
cy.contains(".recipe-table__name", title).should("be.visible");
|
|
});
|
|
|
|
When("I click the source item {string}", (title: string) => {
|
|
cy.contains(".recipe-table__name", title).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");
|
|
});
|
|
|
|
// ImportRecipePage (the review screen) loads its own ingredient/diet/unit
|
|
// catalogs the same way RecipeFormPage does — "onion" matches the resolved
|
|
// line in "previewing TheMealDB item ... is available" above, "salt" is
|
|
// what "some mystery paste" (unresolved in that same fixture) gets
|
|
// corrected to in the review-and-import scenario.
|
|
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", { statusCode: 201, body: { id } }).as(
|
|
"importRecipe",
|
|
);
|
|
});
|
|
|
|
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 unresolved ingredients section should no longer be shown", () => {
|
|
cy.get(".import-recipe__unresolved").should("not.exist");
|
|
});
|
|
|
|
Then(
|
|
"the import request should have included ingredient {int} with quantity {int} and unitId {int}",
|
|
(ingredientId: number, quantity: number, unitId: number) => {
|
|
cy.wait("@importRecipe")
|
|
.its("request.body.ingredients")
|
|
.should("include.deep.members", [{ ingredientId, quantity, unitId }]);
|
|
},
|
|
);
|