From 68292c29066447f5d636188499cdf220280f3914 Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Thu, 14 May 2026 19:01:06 -0500 Subject: [PATCH] Harden recipe edit page and cover allowedUnitCategories projection Edit page now surfaces load and save errors instead of failing silently, round-trips sourceUrl through PUT, warns before discarding unsaved changes, and offers an explicit Cancel button. Delete moved off the detail page's primary action row into a less-prominent footer link so a mis-tap on Edit can no longer destroy the recipe. Added integration tests covering AllowedUnitCategories in the recipe GET projection for all four product-link shapes (global no-override, global with override, family product, unlinked). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Builders/RecipeBuilder.cs | 11 +- .../Features/RecipeEndpointsTests.cs | 94 ++++++++++++++ .../src/routes/recipes/[id]/+page.svelte | 15 ++- .../src/routes/recipes/[id]/edit/+page.svelte | 122 +++++++++++++----- 4 files changed, 200 insertions(+), 42 deletions(-) diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs index fcb8e58..6c5a12b 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs @@ -24,7 +24,14 @@ public sealed class RecipeBuilder public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; } public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; } - public RecipeBuilder WithIngredient(string name, int sortOrder = 0, decimal? quantity = null, int? unitOfMeasureId = null, int? familyUnitOfMeasureId = null) + public RecipeBuilder WithIngredient( + string name, + int sortOrder = 0, + decimal? quantity = null, + int? unitOfMeasureId = null, + int? familyUnitOfMeasureId = null, + int? productId = null, + int? familyProductId = null) { _ingredients.Add(new RecipeIngredient { @@ -33,6 +40,8 @@ public sealed class RecipeBuilder Quantity = quantity, UnitOfMeasureId = unitOfMeasureId, FamilyUnitOfMeasureId = familyUnitOfMeasureId, + ProductId = productId, + FamilyProductId = familyProductId, }); return this; } diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs index 0227365..c8a946b 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs @@ -336,6 +336,100 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume); } + [Test] + public async Task Get_returns_allowed_unit_categories_from_global_product_when_no_override() + { + var familyId = await UseDbAsync(db => + db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync()); + var productId = await UseDbAsync(async db => + { + var p = new Product { Name = "Flour-base", AllowedUnitCategories = UnitCategoryFlags.Weight | UnitCategoryFlags.Volume }; + db.Products.Add(p); + await db.SaveChangesAsync(); + return p.Id; + }); + var recipe = await CreateRecipeAsync(b => b + .Titled("Bread") + .WithIngredient("flour", sortOrder: 1, quantity: 2m, productId: productId)); + + var body = await Client.GetFromJsonAsync($"/api/recipes/{recipe.Id}"); + + var ingredient = body.GetProperty("ingredients").EnumerateArray().Single(); + var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32(); + await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume); + } + + [Test] + public async Task Get_returns_override_allowed_unit_categories_when_family_overrides_product() + { + var familyId = await UseDbAsync(db => + db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync()); + var productId = await UseDbAsync(async db => + { + var p = new Product { Name = "Flour-ovr", AllowedUnitCategories = UnitCategoryFlags.Weight }; + db.Products.Add(p); + await db.SaveChangesAsync(); + db.Set().Add(new FamilyProductOverride + { + FamilyId = familyId, + ProductId = p.Id, + AllowedUnitCategories = UnitCategoryFlags.Volume | UnitCategoryFlags.Count, + }); + await db.SaveChangesAsync(); + return p.Id; + }); + var recipe = await CreateRecipeAsync(b => b + .Titled("Pancakes-ovr") + .WithIngredient("flour", sortOrder: 1, quantity: 1m, productId: productId)); + + var body = await Client.GetFromJsonAsync($"/api/recipes/{recipe.Id}"); + + var ingredient = body.GetProperty("ingredients").EnumerateArray().Single(); + var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32(); + await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Count); + } + + [Test] + public async Task Get_returns_allowed_unit_categories_from_family_product() + { + var familyId = await UseDbAsync(db => + db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync()); + var familyProductId = await UseDbAsync(async db => + { + var fp = new FamilyProduct + { + FamilyId = familyId, + Name = "House Flour-fp", + AllowedUnitCategories = UnitCategoryFlags.Weight | UnitCategoryFlags.Packaging, + }; + db.FamilyProducts.Add(fp); + await db.SaveChangesAsync(); + return fp.Id; + }); + var recipe = await CreateRecipeAsync(b => b + .Titled("Loaf") + .WithIngredient("house flour", sortOrder: 1, quantity: 1m, familyProductId: familyProductId)); + + var body = await Client.GetFromJsonAsync($"/api/recipes/{recipe.Id}"); + + var ingredient = body.GetProperty("ingredients").EnumerateArray().Single(); + var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32(); + await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Packaging); + } + + [Test] + public async Task Get_returns_zero_allowed_unit_categories_for_unlinked_ingredient() + { + var recipe = await CreateRecipeAsync(b => b + .Titled("Mystery") + .WithIngredient("salt", sortOrder: 1)); + + var body = await Client.GetFromJsonAsync($"/api/recipes/{recipe.Id}"); + + var ingredient = body.GetProperty("ingredients").EnumerateArray().Single(); + await Assert.That(ingredient.GetProperty("allowedUnitCategories").GetInt32()).IsEqualTo(0); + } + [Test] public async Task Create_accumulates_distinct_categories_across_ingredients() { diff --git a/src/frontend/src/routes/recipes/[id]/+page.svelte b/src/frontend/src/routes/recipes/[id]/+page.svelte index 49a088d..6de2d5e 100644 --- a/src/frontend/src/routes/recipes/[id]/+page.svelte +++ b/src/frontend/src/routes/recipes/[id]/+page.svelte @@ -107,12 +107,6 @@ > Edit - {#if showAddToList} @@ -162,5 +156,14 @@ {/if} + +
+ +
{/if} diff --git a/src/frontend/src/routes/recipes/[id]/edit/+page.svelte b/src/frontend/src/routes/recipes/[id]/edit/+page.svelte index b8a5ebd..a6a9794 100644 --- a/src/frontend/src/routes/recipes/[id]/edit/+page.svelte +++ b/src/frontend/src/routes/recipes/[id]/edit/+page.svelte @@ -1,7 +1,7 @@