Add per-store sections to group list items by walk order

Each store gets a StoreSection catalog (Produce, Dairy, etc.). Default
sections are seeded on store creation; admins can rename, reorder, add,
or delete. ShoppingListItem.SectionId is a nullable FK that sets to
null when the section is deleted, so items survive section churn.

The list detail view groups items by section in walk order, with
Uncategorized appended last. Section dropdowns on each row (and the
add-item form) let users assign or reassign on the fly. SignalR
broadcasts include sectionId on adds and a new ItemSectionChanged
event for live re-grouping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-08 22:05:57 -05:00
parent de5c18f3e6
commit fa1d4b4f62
14 changed files with 1413 additions and 82 deletions
@@ -0,0 +1,245 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using YesChef.Api.Entities;
using YesChef.Api.Features.Stores;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
public class StoreSectionEndpointsTests : AuthenticatedIntegrationTest
{
public Store Store { get; private set; } = null!;
[Before(Test)]
public async Task SetUpStore()
{
Store = await Data.CreateStoreAsync();
}
[Test]
public async Task Create_store_seeds_default_sections()
{
var response = await Client.PostAsJsonAsync("/api/stores",
new StoreEndpoints.CreateStoreRequest("Whole Foods"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var newStore = await UseDbAsync(db => db.Stores.SingleAsync(s => s.Name == "Whole Foods"));
var seeded = await UseDbAsync(db =>
db.StoreSections.Where(s => s.StoreId == newStore.Id).OrderBy(s => s.SortOrder).Select(s => s.Name).ToListAsync());
await Assert.That(seeded).IsEquivalentTo(StoreSectionDefaults.Names);
}
[Test]
public async Task List_returns_sections_in_sort_order()
{
await UseDbAsync(async db =>
{
db.StoreSections.AddRange(
new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Bravo", SortOrder = 2 },
new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Alpha", SortOrder = 1 },
new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Charlie", SortOrder = 3 });
await db.SaveChangesAsync();
});
var sections = await Client.GetFromJsonAsync<List<JsonElement>>($"/api/stores/{Store.Id}/sections");
var names = sections!.Select(s => s.GetProperty("name").GetString()!).ToArray();
await Assert.That(names).IsEquivalentTo(new[] { "Alpha", "Bravo", "Charlie" });
}
[Test]
public async Task List_returns_404_for_unknown_store()
{
var response = await Client.GetAsync("/api/stores/99999/sections");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Create_persists_section()
{
var response = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections",
new StoreSectionEndpoints.CreateSectionRequest("Produce", 0));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var section = await UseDbAsync(db => db.StoreSections.SingleAsync(s => s.StoreId == Store.Id));
await Assert.That(section.Name).IsEqualTo("Produce");
}
[Test]
public async Task Create_returns_409_for_duplicate_name()
{
await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections",
new StoreSectionEndpoints.CreateSectionRequest("Produce"));
var response = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections",
new StoreSectionEndpoints.CreateSectionRequest("Produce"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
[Test]
public async Task Update_changes_name_and_sort_order()
{
var created = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections",
new StoreSectionEndpoints.CreateSectionRequest("Produce"));
var section = await created.Content.ReadFromJsonAsync<JsonElement>();
var sectionId = section.GetProperty("id").GetInt32();
var response = await Client.PutAsJsonAsync($"/api/stores/{Store.Id}/sections/{sectionId}",
new StoreSectionEndpoints.UpdateSectionRequest("Fresh", 5));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var refreshed = await UseDbAsync(db => db.StoreSections.SingleAsync(s => s.Id == sectionId));
await Assert.That(refreshed.Name).IsEqualTo("Fresh");
await Assert.That(refreshed.SortOrder).IsEqualTo(5);
}
[Test]
public async Task Delete_removes_section_and_keeps_items_with_null_section()
{
var sectionId = await UseDbAsync(async db =>
{
var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" };
db.StoreSections.Add(section);
await db.SaveChangesAsync();
return section.Id;
});
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas"));
await UseDbAsync(async db =>
{
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.SectionId = sectionId;
await db.SaveChangesAsync();
});
var response = await Client.DeleteAsync($"/api/stores/{Store.Id}/sections/{sectionId}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id));
await Assert.That(item.SectionId).IsNull();
}
[Test]
public async Task Sections_are_isolated_per_family()
{
await UseDbAsync(async db =>
{
db.StoreSections.Add(new StoreSection
{
FamilyId = Store.FamilyId,
StoreId = Store.Id,
Name = "Mine",
});
await db.SaveChangesAsync();
});
var otherFamily = await UseDbAsync(async db =>
{
var family = new Family { Name = "Other", InviteCode = $"other-{Guid.NewGuid():N}" };
db.Families.Add(family);
await db.SaveChangesAsync();
return family;
});
var otherStore = await UseDbAsync(async db =>
{
var store = new Store { FamilyId = otherFamily.Id, Name = "Other Store" };
db.Stores.Add(store);
await db.SaveChangesAsync();
return store;
});
// The default user can't see another family's store.
var response = await Client.GetAsync($"/api/stores/{otherStore.Id}/sections");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Add_item_with_section_persists_section_id()
{
var sectionId = await UseDbAsync(async db =>
{
var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" };
db.StoreSections.Add(section);
await db.SaveChangesAsync();
return section.Id;
});
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User));
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.AddItemRequest("Bananas", 0, sectionId));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id));
await Assert.That(item.SectionId).IsEqualTo(sectionId);
}
[Test]
public async Task Add_item_rejects_section_from_other_store()
{
var otherStore = await Data.CreateStoreAsync(b => b.Named("Other"));
var alienSectionId = await UseDbAsync(async db =>
{
var section = new StoreSection { FamilyId = otherStore.FamilyId, StoreId = otherStore.Id, Name = "Alien" };
db.StoreSections.Add(section);
await db.SaveChangesAsync();
return section.Id;
});
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User));
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.AddItemRequest("Bananas", 0, alienSectionId));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Set_item_section_updates_existing_item()
{
var sectionId = await UseDbAsync(async db =>
{
var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" };
db.StoreSections.Add(section);
await db.SaveChangesAsync();
return section.Id;
});
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas"));
var itemId = list.Items[0].Id;
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.SetItemSectionRequest(sectionId));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
await Assert.That(item.SectionId).IsEqualTo(sectionId);
}
[Test]
public async Task Set_item_section_clears_when_null()
{
var sectionId = await UseDbAsync(async db =>
{
var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" };
db.StoreSections.Add(section);
await db.SaveChangesAsync();
return section.Id;
});
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas"));
var itemId = list.Items[0].Id;
await UseDbAsync(async db =>
{
var item = await db.ShoppingListItems.SingleAsync(i => i.Id == itemId);
item.SectionId = sectionId;
await db.SaveChangesAsync();
});
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.SetItemSectionRequest(null));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
await Assert.That(item.SectionId).IsNull();
}
}