diff --git a/apps/web/cypress/e2e/recipe-form.cy.ts b/apps/web/cypress/e2e/recipe-form.cy.ts new file mode 100644 index 0000000..1464c8a --- /dev/null +++ b/apps/web/cypress/e2e/recipe-form.cy.ts @@ -0,0 +1,178 @@ +// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no +// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover +// real API behavior against a real database). + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: 1, + dietId: null, +}; + +const tomato = { + id: 1, + key: "tomato", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; +const egg = { + id: 2, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], +}; +const carrot = { + id: 3, + key: "carrot", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; + +const diets = [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, +]; + +function interceptCatalog() { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/reference/ingredients", { + statusCode: 200, + body: [tomato, egg, carrot], + }); + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); +} + +describe("Recipe form — associating ingredients", () => { + beforeEach(() => { + interceptCatalog(); + }); + + it("adds an ingredient from the picker, fills its quantity/unit, and creates the recipe", () => { + cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id: 42 } }).as("createRecipe"); + + cy.visit("/recettes/nouvelle"); + + cy.get("#recipe-name").type("Salade de tomates"); + + cy.get("input[placeholder='Rechercher un ingrédient…']").type("tomat"); + cy.contains(".ingredient-picker__card", "Tomate").click(); + + // The card disappears from the picker once selected (excludeIds), and + // a row for it appears in the recipe's own ingredient list. + cy.contains(".ingredient-picker__card", "Tomate").should("not.exist"); + cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); + + cy.get(".ingredient-row .ingredient-row__quantity").type("3"); + cy.get(".ingredient-row .ingredient-row__unit").type("unité"); + + cy.contains("button", "Ajouter une étape").click(); + cy.get(".step-list-editor__item textarea").type("Couper les tomates."); + + cy.contains("button", "Enregistrer").should("not.be.disabled").click(); + + cy.wait("@createRecipe").its("request.body").should("deep.include", { + name: "Salade de tomates", + ingredients: [{ ingredientId: 1, quantity: 3, unit: "unité" }], + }); + cy.url().should("include", "/recettes/42"); + }); + + // Regression test for the exact bug reported: `crypto.randomUUID()` (used + // to mint each ingredient/step draft's client-only React key) throws + // outside a secure context — https, or literally the hostname + // `localhost` — so a LAN IP during on-device testing or a Capacitor + // WebView's `capacitor://` origin hit a black screen with "TypeError: + // crypto.randomUUID is not a function" the instant an ingredient was + // added. Cypress's own origin is secure, so this forces the same failure + // by deleting `crypto.randomUUID` before the app boots — see + // `apps/web/src/lib/client-key.ts`, which replaced it. + it("still works when crypto.randomUUID is unavailable (insecure-context regression)", () => { + cy.visit("/recettes/nouvelle", { + onBeforeLoad(win) { + Object.defineProperty(win.crypto, "randomUUID", { + value: undefined, + configurable: true, + }); + }, + }); + + cy.get("#recipe-name").type("Recette hors contexte sécurisé"); + + cy.contains(".ingredient-picker__card", "Tomate").click(); + cy.contains(".ingredient-picker__card", "Œuf").click(); + + // Both rows rendered with distinct identities — no crash, no React + // "same key" warning silently collapsing one of them. + cy.get(".ingredient-row").should("have.length", 2); + cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); + cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); + + cy.contains("button", "Ajouter une étape").click(); + cy.contains("button", "Ajouter une étape").click(); + cy.get(".step-list-editor__item").should("have.length", 2); + }); + + it("excludes an already-selected ingredient from the picker, and removing it brings it back", () => { + cy.visit("/recettes/nouvelle"); + + cy.contains(".ingredient-picker__card", "Carotte").click(); + cy.contains(".ingredient-picker__card", "Carotte").should("not.exist"); + + cy.contains(".ingredient-row", "Carotte") + .find("button[title='Retirer cet ingrédient']") + .click(); + cy.contains(".ingredient-picker__card", "Carotte").should("be.visible"); + cy.get(".ingredient-row").should("have.length", 0); + }); + + it("preloads an existing recipe's ingredients when editing, and lets you add another", () => { + const existingRecipe = { + id: 7, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], + isFavorite: false, + ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], + steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], + }; + cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); + cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as( + "updateRecipe", + ); + + cy.visit("/recettes/7/modifier"); + + cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); + cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", "3"); + + cy.contains(".ingredient-picker__card", "Tomate").click(); + cy.get(".ingredient-row").should("have.length", 2); + cy.get(".ingredient-row .ingredient-row__quantity").last().type("1"); + cy.get(".ingredient-row .ingredient-row__unit").last().type("unité"); + + cy.contains("button", "Enregistrer").click(); + + cy.wait("@updateRecipe") + .its("request.body.ingredients") + .should("deep.equal", [ + { ingredientId: 2, quantity: 3, unit: "unité" }, + { ingredientId: 1, quantity: 1, unit: "unité" }, + ]); + }); +}); diff --git a/apps/web/src/features/recipes/StepListEditor.tsx b/apps/web/src/features/recipes/StepListEditor.tsx index 70a8877..a809bd5 100644 --- a/apps/web/src/features/recipes/StepListEditor.tsx +++ b/apps/web/src/features/recipes/StepListEditor.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { makeClientKey } from "../../lib/client-key"; import "./recipes.scss"; /** One in-progress preparation step in the recipe form. `key` is a client-only stable identity for React/reordering — the server derives the real `order`/`id` from array position on save (see `schemas/recipe.ts`), never from this. */ @@ -24,7 +25,7 @@ export function StepListEditor({ const { t } = useTranslation(); function addStep() { - onChange([...steps, { key: crypto.randomUUID(), description: "", picture: "" }]); + onChange([...steps, { key: makeClientKey(), description: "", picture: "" }]); } function updateStep(key: string, patch: Partial>) { diff --git a/apps/web/src/lib/client-key.ts b/apps/web/src/lib/client-key.ts new file mode 100644 index 0000000..48aea87 --- /dev/null +++ b/apps/web/src/lib/client-key.ts @@ -0,0 +1,24 @@ +/** + * Generates a client-only, ephemeral identity for a draft list row (a + * recipe's ingredient lines/steps while editing — see `RecipeFormPage`, + * `StepListEditor`) — used purely as a React `key`/local identity to keep + * each row's own input state stable across reorders, never sent to the + * server or persisted anywhere. + * + * `crypto.randomUUID()` looks like the obvious choice, but the Web Crypto + * API only defines it in a "secure context" — https, or the literal + * hostname `localhost` — and throws `TypeError: crypto.randomUUID is not a + * function` everywhere else: a LAN IP while testing on a real device, a + * Capacitor WebView's `capacitor://` origin (this app's eventual mobile + * wrapper — see `AllergySelect`'s doc comment for the same "mobile via + * Capacitor" context), plain `http://` on a real domain before TLS is set + * up. None of that matters for a value that only ever needs to be *unique + * within this one render session*, not cryptographically random, so this + * never touches `crypto` at all. + */ +let counter = 0; + +export function makeClientKey(): string { + counter += 1; + return `${counter}-${Math.random().toString(36).slice(2)}`; +} diff --git a/apps/web/src/pages/RecipeFormPage.tsx b/apps/web/src/pages/RecipeFormPage.tsx index 4385b97..f4012c7 100644 --- a/apps/web/src/pages/RecipeFormPage.tsx +++ b/apps/web/src/pages/RecipeFormPage.tsx @@ -15,6 +15,7 @@ import { IngredientPicker } from "../features/recipes/IngredientPicker"; import { IngredientRow } from "../features/recipes/IngredientRow"; import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor"; import "../features/recipes/recipes.scss"; +import { makeClientKey } from "../lib/client-key"; import { errorMessageService } from "../services/error-message.service"; /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */ @@ -82,7 +83,7 @@ export function RecipeFormPage() { setDietIds(recipe.diets.map((diet) => diet.id)); setIngredientLines( recipe.ingredients.map((line) => ({ - key: crypto.randomUUID(), + key: makeClientKey(), ingredient: line.ingredient, quantity: String(line.quantity), unit: line.unit, @@ -90,7 +91,7 @@ export function RecipeFormPage() { ); setSteps( recipe.steps.map((step) => ({ - key: crypto.randomUUID(), + key: makeClientKey(), description: step.description, picture: step.picture ?? "", })), @@ -110,7 +111,7 @@ export function RecipeFormPage() { function addIngredient(ingredient: IngredientView) { setIngredientLines((lines) => [ ...lines, - { key: crypto.randomUUID(), ingredient, quantity: "", unit: "" }, + { key: makeClientKey(), ingredient, quantity: "", unit: "" }, ]); }