Add structured quantities + units to recipe ingredients
Phase 2 of structured quantities + UoM. Replaces the free-form Quantity string on RecipeIngredient with a structured (Quantity decimal, UnitOfMeasureId or FamilyUnitOfMeasureId) pair, plus an IsApproximate + QuantityNote escape hatch for "to taste" style entries. The unit FK pair mirrors the existing Product / FamilyProduct pattern, with the same at-most-one and tenant-scoping validation. Existing string Quantity values are dropped per the agreed wipe-to-null migration plan. Frontend ships a QuantityInput component (numeric field + unit dropdown fed by a runes-cached effective catalog from /api/units) and a shared formatter for read-only display. Recipe -> shopping list copy folds the structured quantity into the item Name for now; Phase 3 will move the fields onto ShoppingListItem directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,9 +24,28 @@ 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, string? quantity = null, int sortOrder = 0)
|
||||
public RecipeBuilder WithIngredient(string name, int sortOrder = 0, decimal? quantity = null, int? unitOfMeasureId = null, int? familyUnitOfMeasureId = null)
|
||||
{
|
||||
_ingredients.Add(new RecipeIngredient { Name = name, Quantity = quantity, SortOrder = sortOrder });
|
||||
_ingredients.Add(new RecipeIngredient
|
||||
{
|
||||
Name = name,
|
||||
SortOrder = sortOrder,
|
||||
Quantity = quantity,
|
||||
UnitOfMeasureId = unitOfMeasureId,
|
||||
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public RecipeBuilder WithApproximateIngredient(string name, string quantityNote, int sortOrder = 0)
|
||||
{
|
||||
_ingredients.Add(new RecipeIngredient
|
||||
{
|
||||
Name = name,
|
||||
SortOrder = sortOrder,
|
||||
IsApproximate = true,
|
||||
QuantityNote = quantityNote,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
SourceUrl: "https://example.com/pasta",
|
||||
Ingredients:
|
||||
[
|
||||
new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1),
|
||||
new RecipeEndpoints.IngredientRequest("salt", null, 2),
|
||||
new RecipeEndpoints.IngredientRequest("pasta", 1, Quantity: 1m),
|
||||
new RecipeEndpoints.IngredientRequest("salt", 2),
|
||||
]);
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||
@@ -54,7 +54,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
Instructions: null,
|
||||
Servings: null,
|
||||
SourceUrl: null,
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1, ProductId: product.Id)]);
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("pasta", 1, Quantity: 1m, ProductId: product.Id)]);
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||
|
||||
@@ -68,7 +68,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
{
|
||||
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, ProductId: 1, FamilyProductId: 1)]);
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, ProductId: 1, FamilyProductId: 1)]);
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||
|
||||
@@ -91,7 +91,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
|
||||
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, FamilyProductId: foreignProductId)]);
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, FamilyProductId: foreignProductId)]);
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||
|
||||
@@ -103,8 +103,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
{
|
||||
var recipe = await CreateRecipeAsync(b => b
|
||||
.Titled("Soup").Describes("warm").Serves(2)
|
||||
.WithIngredient("water", "4 cups", 2)
|
||||
.WithIngredient("broth cube", "1", 1));
|
||||
.WithIngredient("water", sortOrder: 2, quantity: 4m)
|
||||
.WithIngredient("broth cube", sortOrder: 1, quantity: 1m));
|
||||
|
||||
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
|
||||
|
||||
@@ -140,8 +140,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
{
|
||||
var recipe = await CreateRecipeAsync(b => b
|
||||
.Titled("Stew")
|
||||
.WithIngredient("beef", "1 lb", 1)
|
||||
.WithIngredient("carrot", "2", 2));
|
||||
.WithIngredient("beef", sortOrder: 1, quantity: 1m)
|
||||
.WithIngredient("carrot", sortOrder: 2, quantity: 2m));
|
||||
|
||||
var update = new RecipeEndpoints.UpdateRecipeRequest(
|
||||
Title: "Veggie Stew",
|
||||
@@ -151,9 +151,9 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
SourceUrl: null,
|
||||
Ingredients:
|
||||
[
|
||||
new RecipeEndpoints.IngredientRequest("potato", "3", 1),
|
||||
new RecipeEndpoints.IngredientRequest("onion", "1", 2),
|
||||
new RecipeEndpoints.IngredientRequest("carrot", "4", 3),
|
||||
new RecipeEndpoints.IngredientRequest("potato", 1, Quantity: 3m),
|
||||
new RecipeEndpoints.IngredientRequest("onion", 2, Quantity: 1m),
|
||||
new RecipeEndpoints.IngredientRequest("carrot", 3, Quantity: 4m),
|
||||
]);
|
||||
|
||||
var response = await Client.PutAsJsonAsync($"/api/recipes/{recipe.Id}", update);
|
||||
@@ -173,7 +173,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
{
|
||||
var recipe = await CreateRecipeAsync(b => b
|
||||
.Titled("Toast")
|
||||
.WithIngredient("bread", "2 slices", 1));
|
||||
.WithIngredient("bread", sortOrder: 1, quantity: 2m));
|
||||
|
||||
var response = await Client.DeleteAsync($"/api/recipes/{recipe.Id}");
|
||||
|
||||
@@ -181,4 +181,121 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
||||
await Assert.That(await UseDbAsync(db => db.Recipes.CountAsync())).IsEqualTo(0);
|
||||
await Assert.That(await UseDbAsync(db => db.RecipeIngredients.CountAsync())).IsEqualTo(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_persists_structured_quantity_and_global_unit()
|
||||
{
|
||||
var unitId = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new UnitOfMeasure
|
||||
{
|
||||
Code = "lb", SingularName = "pound", PluralName = "pounds",
|
||||
Abbreviation = "lb", Category = UnitCategory.Weight,
|
||||
};
|
||||
db.UnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u.Id;
|
||||
});
|
||||
|
||||
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||
Title: "Burger", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("beef", 1, Quantity: 1.5m, UnitOfMeasureId: unitId)]);
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||
var ing = await UseDbAsync(db => db.RecipeIngredients.SingleAsync());
|
||||
await Assert.That(ing.Quantity).IsEqualTo(1.5m);
|
||||
await Assert.That(ing.UnitOfMeasureId).IsEqualTo(unitId);
|
||||
await Assert.That(ing.FamilyUnitOfMeasureId).IsNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_persists_approximate_ingredient_with_note()
|
||||
{
|
||||
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||
Title: "Stew", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("salt", 1, IsApproximate: true, QuantityNote: "to taste")]);
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||
var ing = await UseDbAsync(db => db.RecipeIngredients.SingleAsync());
|
||||
await Assert.That(ing.IsApproximate).IsTrue();
|
||||
await Assert.That(ing.QuantityNote).IsEqualTo("to taste");
|
||||
await Assert.That(ing.Quantity).IsNull();
|
||||
await Assert.That(ing.UnitOfMeasureId).IsNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_rejects_ingredient_with_both_unit_ids()
|
||||
{
|
||||
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, Quantity: 1m, UnitOfMeasureId: 1, FamilyUnitOfMeasureId: 1)]);
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_rejects_ingredient_referencing_other_familys_unit()
|
||||
{
|
||||
var foreignUnitId = await UseDbAsync(async db =>
|
||||
{
|
||||
var other = new Family { Name = "Other", InviteCode = "other-code" };
|
||||
db.Families.Add(other);
|
||||
await db.SaveChangesAsync();
|
||||
var u = new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = other.Id,
|
||||
SingularName = "pinch", PluralName = "pinches",
|
||||
Abbreviation = "pn", Category = UnitCategory.Volume,
|
||||
};
|
||||
db.FamilyUnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u.Id;
|
||||
});
|
||||
|
||||
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||
Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null,
|
||||
Ingredients: [new RecipeEndpoints.IngredientRequest("salt", 1, Quantity: 1m, FamilyUnitOfMeasureId: foreignUnitId)]);
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Get_projects_structured_quantity_fields()
|
||||
{
|
||||
var unitId = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new UnitOfMeasure
|
||||
{
|
||||
Code = "cup", SingularName = "cup", PluralName = "cups",
|
||||
Abbreviation = "cup", Category = UnitCategory.Volume,
|
||||
};
|
||||
db.UnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u.Id;
|
||||
});
|
||||
var recipe = await CreateRecipeAsync(b => b
|
||||
.Titled("Pancakes")
|
||||
.WithIngredient("flour", sortOrder: 1, quantity: 2m, unitOfMeasureId: unitId)
|
||||
.WithApproximateIngredient("salt", quantityNote: "to taste", sortOrder: 2));
|
||||
|
||||
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
|
||||
|
||||
var ingredients = body.GetProperty("ingredients").EnumerateArray().ToArray();
|
||||
var flour = ingredients.Single(i => i.GetProperty("name").GetString() == "flour");
|
||||
await Assert.That(flour.GetProperty("quantity").GetDecimal()).IsEqualTo(2m);
|
||||
await Assert.That(flour.GetProperty("unitOfMeasureId").GetInt32()).IsEqualTo(unitId);
|
||||
await Assert.That(flour.GetProperty("isApproximate").GetBoolean()).IsFalse();
|
||||
|
||||
var salt = ingredients.Single(i => i.GetProperty("name").GetString() == "salt");
|
||||
await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue();
|
||||
await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,12 +443,23 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
||||
[Test]
|
||||
public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix()
|
||||
{
|
||||
var cupId = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new UnitOfMeasure
|
||||
{
|
||||
Code = "cup", SingularName = "cup", PluralName = "cups",
|
||||
Abbreviation = "cup", Category = UnitCategory.Volume,
|
||||
};
|
||||
db.UnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u.Id;
|
||||
});
|
||||
var list = await CreateListAsync(b => b.WithItem("existing", sortOrder: 5));
|
||||
var recipe = await Data.CreateRecipeAsync(b => b
|
||||
.Titled("Pancakes").CreatedBy(User)
|
||||
.WithIngredient("flour", "2 cups", 1)
|
||||
.WithIngredient("eggs", "2", 2)
|
||||
.WithIngredient("salt", null, 3));
|
||||
.WithIngredient("flour", sortOrder: 1, quantity: 2m, unitOfMeasureId: cupId)
|
||||
.WithIngredient("eggs", sortOrder: 2, quantity: 2m)
|
||||
.WithApproximateIngredient("salt", quantityNote: "to taste", sortOrder: 3));
|
||||
|
||||
var response = await Client.PostAsync($"/api/lists/{list.Id}/add-recipe/{recipe.Id}", null);
|
||||
|
||||
@@ -460,7 +471,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
||||
await Assert.That(items.Count).IsEqualTo(4);
|
||||
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
|
||||
{
|
||||
"existing", "2 cups flour", "2 eggs", "salt"
|
||||
"existing", "2 cup flour", "2 eggs", "salt (to taste)"
|
||||
});
|
||||
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
|
||||
await Assert.That(items[3].SortOrder).IsGreaterThan(5);
|
||||
|
||||
@@ -197,6 +197,43 @@ public class UnitEndpointsTests : AuthenticatedIntegrationTest
|
||||
await Assert.That(remaining).IsEqualTo(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_family_unit_returns_409_when_referenced_by_recipe_ingredient()
|
||||
{
|
||||
var familyId = await GetFamilyIdAsync();
|
||||
var unit = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = familyId,
|
||||
SingularName = "pinch", PluralName = "pinches",
|
||||
Abbreviation = "pn", Category = UnitCategory.Volume,
|
||||
};
|
||||
db.FamilyUnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var recipe = new Recipe
|
||||
{
|
||||
FamilyId = familyId,
|
||||
Title = "Soup",
|
||||
CreatedByUserId = User.Id,
|
||||
Ingredients = new List<RecipeIngredient>
|
||||
{
|
||||
new() { FamilyId = familyId, Name = "salt", SortOrder = 1, Quantity = 1m, FamilyUnitOfMeasureId = u.Id },
|
||||
},
|
||||
};
|
||||
db.Recipes.Add(recipe);
|
||||
await db.SaveChangesAsync();
|
||||
return u;
|
||||
});
|
||||
|
||||
var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}");
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||
var remaining = await UseDbAsync(db => db.FamilyUnitsOfMeasure.CountAsync());
|
||||
await Assert.That(remaining).IsEqualTo(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Endpoints_require_authentication()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user