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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user