Add structured quantities + units to shopping list items

Mirrors the Phase 2 work on RecipeIngredient: ShoppingListItem grows
Quantity (decimal), UnitOfMeasureId / FamilyUnitOfMeasureId, IsApproximate,
and QuantityNote. The recipe-to-list copy now carries structured fields
verbatim instead of folding them into the free-form Name, and the
unit-in-use guard now also blocks deleting a family unit that's referenced
by a shopping list item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-13 21:18:26 -05:00
parent c7f9c31952
commit fb1bc2b7e1
11 changed files with 1606 additions and 64 deletions
@@ -21,9 +21,35 @@ public sealed class ShoppingListBuilder
public ShoppingListBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
public ShoppingListBuilder Archived() { _archived = true; return this; }
public ShoppingListBuilder WithItem(string name, bool isChecked = false, int sortOrder = 0)
public ShoppingListBuilder WithItem(
string name,
bool isChecked = false,
int sortOrder = 0,
decimal? quantity = null,
int? unitOfMeasureId = null,
int? familyUnitOfMeasureId = null)
{
_items.Add(new ShoppingListItem { Name = name, IsChecked = isChecked, SortOrder = sortOrder });
_items.Add(new ShoppingListItem
{
Name = name,
IsChecked = isChecked,
SortOrder = sortOrder,
Quantity = quantity,
UnitOfMeasureId = unitOfMeasureId,
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
});
return this;
}
public ShoppingListBuilder WithApproximateItem(string name, string quantityNote, int sortOrder = 0)
{
_items.Add(new ShoppingListItem
{
Name = name,
SortOrder = sortOrder,
IsApproximate = true,
QuantityNote = quantityNote,
});
return this;
}
@@ -441,7 +441,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
}
[Test]
public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix()
public async Task Add_recipe_copies_structured_quantity_to_items()
{
var cupId = await UseDbAsync(async db =>
{
@@ -471,10 +471,134 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(items.Count).IsEqualTo(4);
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
{
"existing", "2 cup flour", "2 eggs", "salt (to taste)"
"existing", "flour", "eggs", "salt"
});
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
await Assert.That(items[3].SortOrder).IsGreaterThan(5);
var flour = items.Single(i => i.Name == "flour");
await Assert.That(flour.Quantity).IsEqualTo(2m);
await Assert.That(flour.UnitOfMeasureId).IsEqualTo(cupId);
await Assert.That(flour.IsApproximate).IsFalse();
var eggs = items.Single(i => i.Name == "eggs");
await Assert.That(eggs.Quantity).IsEqualTo(2m);
await Assert.That(eggs.UnitOfMeasureId).IsNull();
var salt = items.Single(i => i.Name == "salt");
await Assert.That(salt.IsApproximate).IsTrue();
await Assert.That(salt.QuantityNote).IsEqualTo("to taste");
await Assert.That(salt.Quantity).IsNull();
}
[Test]
public async Task Add_item_persists_structured_quantity()
{
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();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("flour", Quantity: 2.5m, UnitOfMeasureId: cupId));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
await Assert.That(item.Quantity).IsEqualTo(2.5m);
await Assert.That(item.UnitOfMeasureId).IsEqualTo(cupId);
await Assert.That(item.IsApproximate).IsFalse();
}
[Test]
public async Task Add_item_persists_approximate_note_and_clears_structured_fields()
{
var list = await CreateListAsync();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("salt", Quantity: 1m, IsApproximate: true, QuantityNote: "to taste"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
await Assert.That(item.IsApproximate).IsTrue();
await Assert.That(item.QuantityNote).IsEqualTo("to taste");
await Assert.That(item.Quantity).IsNull();
await Assert.That(item.UnitOfMeasureId).IsNull();
}
[Test]
public async Task Add_item_rejects_setting_both_unit_ids()
{
var list = await CreateListAsync();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("flour", UnitOfMeasureId: 1, FamilyUnitOfMeasureId: 1));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Add_item_rejects_other_familys_family_unit()
{
var list = await CreateListAsync();
var foreignUnitId = await UseDbAsync(async db =>
{
var otherFamily = new Family { Name = "Other-Unit", InviteCode = "other-unit-code" };
db.Families.Add(otherFamily);
await db.SaveChangesAsync();
var u = new FamilyUnitOfMeasure
{
FamilyId = otherFamily.Id,
SingularName = "scoop", PluralName = "scoops",
Abbreviation = "scp", Category = UnitCategory.Count,
};
db.FamilyUnitsOfMeasure.Add(u);
await db.SaveChangesAsync();
return u.Id;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("flour", FamilyUnitOfMeasureId: foreignUnitId));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Get_list_projects_structured_quantity_fields()
{
var cupId = await UseDbAsync(async db =>
{
var u = new UnitOfMeasure
{
Code = "cup-proj", SingularName = "cup", PluralName = "cups",
Abbreviation = "cup-proj", Category = UnitCategory.Volume,
};
db.UnitsOfMeasure.Add(u);
await db.SaveChangesAsync();
return u.Id;
});
var list = await CreateListAsync(b => b
.WithItem("flour", quantity: 3m, unitOfMeasureId: cupId)
.WithApproximateItem("salt", "to taste", sortOrder: 2));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
var items = body.GetProperty("items").EnumerateArray().ToArray();
var flour = items.Single(i => i.GetProperty("name").GetString() == "flour");
await Assert.That(flour.GetProperty("quantity").GetDecimal()).IsEqualTo(3m);
await Assert.That(flour.GetProperty("unitOfMeasureId").GetInt32()).IsEqualTo(cupId);
await Assert.That(flour.GetProperty("isApproximate").GetBoolean()).IsFalse();
var salt = items.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");
await Assert.That(salt.GetProperty("quantity").ValueKind).IsEqualTo(JsonValueKind.Null);
}
[Test]
@@ -234,6 +234,43 @@ public class UnitEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(remaining).IsEqualTo(1);
}
[Test]
public async Task Delete_family_unit_returns_409_when_referenced_by_shopping_list_item()
{
var familyId = await GetFamilyIdAsync();
var store = await Data.CreateStoreAsync();
var unit = await UseDbAsync(async db =>
{
var u = new FamilyUnitOfMeasure
{
FamilyId = familyId,
SingularName = "scoop", PluralName = "scoops",
Abbreviation = "scp", Category = UnitCategory.Count,
};
db.FamilyUnitsOfMeasure.Add(u);
await db.SaveChangesAsync();
var list = new ShoppingList
{
FamilyId = familyId,
Name = "List with unit",
StoreId = store.Id,
CreatedByUserId = User.Id,
Items = new List<ShoppingListItem>
{
new() { FamilyId = familyId, Name = "ice cream", Quantity = 2m, FamilyUnitOfMeasureId = u.Id },
},
};
db.ShoppingLists.Add(list);
await db.SaveChangesAsync();
return u;
});
var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
[Test]
public async Task Endpoints_require_authentication()
{