// 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é" }, ]); }); });