diff --git a/.claude/settings.json b/.claude/settings.json index 1603bb8..e965543 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -19,7 +19,11 @@ "Bash(xargs -I{} sh -c 'echo \"=== {} ===\"; cat {}')", "Bash(rm -f \"g:/development/C Sharp AI/CartWise/src/CartWise.Web/App_Data/cartwise.db\"*)", "Bash(powershell -Command 'Get-Process | Where-Object { $_.Id -eq 55048 } | Select-Object Id, ProcessName, Path')", - "Bash(powershell -Command 'Get-Process | Where-Object { $_.ProcessName -like '\\\\''*CartWise*'\\\\'' } | Select-Object Id, ProcessName, Path')" + "Bash(powershell -Command 'Get-Process | Where-Object { $_.ProcessName -like '\\\\''*CartWise*'\\\\'' } | Select-Object Id, ProcessName, Path')", + "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify2.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify2.db\" 2>&1 | tail -15 && rm -f verify2.db*)" + ], + "additionalDirectories": [ + "G:\\development\\C Sharp AI\\CartWise" ] } } diff --git a/.editorconfig b/.editorconfig index 92105bc..1278f98 100644 --- a/.editorconfig +++ b/.editorconfig @@ -397,3 +397,7 @@ dotnet_diagnostic.CA1707.severity = none # generic types" guidance. [src/CartWise.Application/Results/**.cs] dotnet_diagnostic.CA1000.severity = none + +# EF Core generates migrations; don't hand-edit generated code to satisfy analyzers. +[src/CartWise.Infrastructure/Data/Migrations/**.cs] +dotnet_diagnostic.CA1861.severity = none diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index 27486b3..f71c0fe 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -302,14 +302,16 @@ A story is done when: - Optimistic concurrency is supported for list items **Tasks** -- [ ] Create `GroceryConcept` -- [ ] Create `ProductCategory` -- [ ] Create `ShoppingList` -- [ ] Create `ShoppingListItem` -- [ ] Add list status enums -- [ ] Add row version concurrency token -- [ ] Add EF configurations -- [ ] Create migration +- [x] Create `GroceryConcept` +- [x] Create `ProductCategory` +- [x] Create `ShoppingList` +- [x] Create `ShoppingListItem` +- [x] Add list status enums +- [x] Add row version concurrency token +- [x] Add EF configurations +- [x] Create migration + +**Status:** Done — `ProductCategory`, `GroceryConcept`, `ShoppingList` (aggregate root with an `Items` backing-field navigation, mirroring `Household`/`HouseholdMember`), and `ShoppingListItem` added in `CartWise.Domain.Entities`; `ShoppingListStatus` (`Active`/`Completed`/`Archived`) and `ShoppingListItemStatus` (`Needed`/`Purchased`/`Skipped`/`Unavailable`/`Substituted`) in `CartWise.Domain.Enums`. Concurrency: `ShoppingListItem.RowVersion` is a `Guid` configured with `.IsConcurrencyToken()` rather than `.IsRowVersion()` — SQLite has no native auto-generated rowversion column, and `IsConcurrencyToken()` works identically on SQLite and a future PostgreSQL move (EF includes the original value in the `WHERE` clause either way); regenerating it on mutation is deferred to `CW-STORY-03.4`, which is where mutation methods land. Scope note: unlike `Household.AddMember`, no `ShoppingList.AddItem(...)` aggregate method exists yet — `ShoppingListItem`'s constructor is public for now since 03.1 is pure modeling and `CW-STORY-03.3` owns the add-item use case; that story should consider tightening `ShoppingListItem`'s constructor to `internal` behind an aggregate method, matching the `Household` pattern. `ShoppingListItem.PreferredProductId` from the AGENTS.md §5.4 spec was intentionally omitted — `Product` doesn't exist until the post-MVP `CW-EPIC-04`, and MVP explicitly doesn't require product resolution; it will be added via a future migration once `Product` exists. `AddShoppingList` migration created and verified applying cleanly against a scratch SQLite database. ### `CW-STORY-03.2` Implement active shopping list service **Release:** MVP @@ -323,11 +325,13 @@ A story is done when: - Household rule for default active list is enforced **Tasks** -- [ ] Create `IShoppingListService` -- [ ] Implement get active list -- [ ] Implement create list -- [ ] Enforce one default active list rule -- [ ] Add application tests +- [x] Create `IShoppingListService` +- [x] Implement get active list +- [x] Implement create list +- [x] Enforce one default active list rule +- [x] Add application tests + +**Status:** Done — `IShoppingListService`/`ShoppingListService` in `CartWise.Application.{Interfaces,Services}`, following the exact pattern established by `HouseholdService` in `CW-STORY-02.2`: `GetActiveListAsync` (read, includes `Items`) and `CreateListAsync` (returns `Result`, rejects a second active list per the AGENTS.md §5.4 v1 rule with a clear error message). `IApplicationDbContext` extended with `ShoppingLists`/`ShoppingListItems` DbSets. Registered in `AddApplication()`. 6 application tests added (EF InMemory, same harness as `HouseholdServiceTests`), including a cross-household isolation check mirroring the pattern from `CW-STORY-02.4`. Also fixed a `CA1861` warning surfaced by the `AddShoppingList` migration's composite-column indexes by scoping a suppression to `Data/Migrations/**` in `.editorconfig` — migrations are generated code and shouldn't be hand-edited to satisfy analyzers. ### `CW-STORY-03.3` Add grocery items quickly **Release:** MVP @@ -342,12 +346,20 @@ A story is done when: - Product resolution is not required to add an item **Tasks** -- [ ] Implement add item use case -- [ ] Support `DisplayName`, quantity, and unit -- [ ] Add validation rules -- [ ] Add item partial view -- [ ] Add controller POST action -- [ ] Add tests +- [x] Implement add item use case +- [x] Support `DisplayName`, quantity, and unit +- [x] Add validation rules +- [x] Add item partial view +- [x] Add controller POST action +- [x] Add tests + +**Status:** Done — `ShoppingList.AddItem(...)` aggregate method added (mirrors `Household.AddMember`; `ShoppingListItem`'s constructor is now `internal`), computing `SortOrder` and rejecting adds to a non-active list. `IShoppingListService.AddItemAsync` added. `ShoppingListController` (`GET /list`, `POST /list/items`) auto-provisions a household's first list (named "Groceries") on first visit — no separate "create list" UI step, matching AGENTS.md's "one active list is enough for MVP" and the First End-to-End Slice in `CLAUDE.md`. `Views/ShoppingList/Index.cshtml` + `_ListItem.cshtml` built (functional, Bootstrap-styled; the dedicated mobile-first/touch-friendly pass is `CW-STORY-03.5`). Scope note carried from `CW-STORY-03.1`'s status resolved: the constructor tightening happened here as anticipated. + +Two real bugs found and fixed via testing, not just the earlier apostrophe-encoding test bug: +1. **EF Core state-tracking bug**: adding a new `ShoppingListItem` purely by mutating the already-tracked parent `ShoppingList`'s backing-field collection (`list.AddItem(...)`) left the new item's state ambiguous to EF Core's change tracker — since `ShoppingListItemId` is a client-generated `Guid` (not store-generated), EF couldn't reliably infer `Added` vs `Modified`, and attempted an `UPDATE` on a row that didn't exist yet, throwing `DbUpdateConcurrencyException`. Fixed by explicitly calling `_db.ShoppingListItems.Add(item)` in `ShoppingListService.AddItemAsync` after `list.AddItem(...)`. +2. **Model-binding prefix mismatch**: `Views/ShoppingList/Index.cshtml`'s tag helpers generate field names like `NewItem.DisplayName` (based on `ShoppingListViewModel.NewItem`'s property path), but `ShoppingListController.AddItem` bound a bare, unprefixed `AddShoppingListItemViewModel model` parameter — so nothing bound, `DisplayName` silently came through empty, and (surprisingly) no validation error surfaced either. Fixed with `[Bind(Prefix = "NewItem")]` on the action parameter. + +Both were caught by genuine integration tests (`ShoppingListAccessControlTests`, real HTTP + real SQLite) written for this story, not by the weaker EF-InMemory application tests alone — worth remembering for future controller/view-model pairs that use a nested form-section pattern like this one. Extracted `WebTestHelpers` (register/create-household/extract-antiforgery-token) out of `HouseholdAccessControlTests` into a shared file so `ShoppingListAccessControlTests` didn't duplicate it. Verified live end-to-end via `dotnet run` + curl, matching `CLAUDE.md`'s First End-to-End Slice exactly: register → create household → `/list` auto-creates "Groceries" → add "Milk" (2 gal) → reload shows it with `Needed` status; empty-name submission correctly redisplays the form with a visible validation error (200, not a redirect). ### `CW-STORY-03.4` Update shopping list items **Release:** MVP diff --git a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs index c634f57..7112cc7 100644 --- a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs +++ b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ public static class ApplicationServiceCollectionExtensions { services.AddSingleton(TimeProvider.System); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs index 293e336..706f82c 100644 --- a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs +++ b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs @@ -9,5 +9,9 @@ public interface IApplicationDbContext DbSet HouseholdMembers { get; } + DbSet ShoppingLists { get; } + + DbSet ShoppingListItems { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/CartWise.Application/Interfaces/IShoppingListService.cs b/src/CartWise.Application/Interfaces/IShoppingListService.cs new file mode 100644 index 0000000..aed95dd --- /dev/null +++ b/src/CartWise.Application/Interfaces/IShoppingListService.cs @@ -0,0 +1,19 @@ +using CartWise.Application.Results; +using CartWise.Domain.Entities; + +namespace CartWise.Application.Interfaces; + +public interface IShoppingListService +{ + Task GetActiveListAsync(Guid householdId, CancellationToken cancellationToken = default); + + Task> CreateListAsync(Guid householdId, string name, string createdByUserId, CancellationToken cancellationToken = default); + + Task> AddItemAsync( + Guid shoppingListId, + string displayName, + decimal? quantity, + string? unit, + string addedByUserId, + CancellationToken cancellationToken = default); +} diff --git a/src/CartWise.Application/Services/ShoppingListService.cs b/src/CartWise.Application/Services/ShoppingListService.cs new file mode 100644 index 0000000..d934669 --- /dev/null +++ b/src/CartWise.Application/Services/ShoppingListService.cs @@ -0,0 +1,91 @@ +using CartWise.Application.Interfaces; +using CartWise.Application.Results; +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Services; + +public class ShoppingListService : IShoppingListService +{ + private readonly IApplicationDbContext _db; + private readonly TimeProvider _timeProvider; + + public ShoppingListService(IApplicationDbContext db, TimeProvider timeProvider) + { + _db = db; + _timeProvider = timeProvider; + } + + public async Task GetActiveListAsync(Guid householdId, CancellationToken cancellationToken = default) + { + return await _db.ShoppingLists + .Include(l => l.Items) + .AsNoTracking() + .Where(l => l.HouseholdId == householdId && l.Status == ShoppingListStatus.Active) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task> CreateListAsync(Guid householdId, string name, string createdByUserId, CancellationToken cancellationToken = default) + { + var hasActiveList = await _db.ShoppingLists + .AsNoTracking() + .AnyAsync(l => l.HouseholdId == householdId && l.Status == ShoppingListStatus.Active, cancellationToken); + + if (hasActiveList) + { + return Result.Failure("This household already has an active shopping list."); + } + + ShoppingList list; + try + { + list = new ShoppingList(householdId, name, createdByUserId, _timeProvider.GetUtcNow().UtcDateTime); + } + catch (ArgumentException ex) + { + return Result.Failure(ex.Message); + } + + _db.ShoppingLists.Add(list); + await _db.SaveChangesAsync(cancellationToken); + + return Result.Success(list); + } + + public async Task> AddItemAsync( + Guid shoppingListId, + string displayName, + decimal? quantity, + string? unit, + string addedByUserId, + CancellationToken cancellationToken = default) + { + var list = await _db.ShoppingLists + .Include(l => l.Items) + .FirstOrDefaultAsync(l => l.ShoppingListId == shoppingListId, cancellationToken); + + if (list is null) + { + return Result.Failure("Shopping list not found."); + } + + ShoppingListItem item; + try + { + item = list.AddItem(displayName, quantity, unit, addedByUserId, _timeProvider.GetUtcNow().UtcDateTime); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Result.Failure(ex.Message); + } + + // EF Core can't reliably infer Added state for a new child discovered only via a mutated + // navigation collection on an already-tracked parent, since ShoppingListItemId is a + // client-generated Guid rather than a store-generated key. Mark it explicitly. + _db.ShoppingListItems.Add(item); + await _db.SaveChangesAsync(cancellationToken); + + return Result.Success(item); + } +} diff --git a/src/CartWise.Domain/Entities/GroceryConcept.cs b/src/CartWise.Domain/Entities/GroceryConcept.cs new file mode 100644 index 0000000..a7d1de4 --- /dev/null +++ b/src/CartWise.Domain/Entities/GroceryConcept.cs @@ -0,0 +1,32 @@ +namespace CartWise.Domain.Entities; + +public class GroceryConcept +{ + public Guid GroceryConceptId { get; private set; } + + public string Name { get; private set; } = null!; + + public string NormalizedName { get; private set; } = null!; + + public Guid? CategoryId { get; private set; } + + public DateTime CreatedUtc { get; private set; } + + private GroceryConcept() + { + } + + public GroceryConcept(string name, DateTime createdUtc, Guid? categoryId = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Grocery concept name is required.", nameof(name)); + } + + GroceryConceptId = Guid.NewGuid(); + Name = name.Trim(); + NormalizedName = Name.ToUpperInvariant(); + CategoryId = categoryId; + CreatedUtc = createdUtc; + } +} diff --git a/src/CartWise.Domain/Entities/ProductCategory.cs b/src/CartWise.Domain/Entities/ProductCategory.cs new file mode 100644 index 0000000..0a9e4f1 --- /dev/null +++ b/src/CartWise.Domain/Entities/ProductCategory.cs @@ -0,0 +1,26 @@ +namespace CartWise.Domain.Entities; + +public class ProductCategory +{ + public Guid ProductCategoryId { get; private set; } + + public string Name { get; private set; } = null!; + + public Guid? ParentCategoryId { get; private set; } + + private ProductCategory() + { + } + + public ProductCategory(string name, Guid? parentCategoryId = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Category name is required.", nameof(name)); + } + + ProductCategoryId = Guid.NewGuid(); + Name = name.Trim(); + ParentCategoryId = parentCategoryId; + } +} diff --git a/src/CartWise.Domain/Entities/ShoppingList.cs b/src/CartWise.Domain/Entities/ShoppingList.cs new file mode 100644 index 0000000..b51caff --- /dev/null +++ b/src/CartWise.Domain/Entities/ShoppingList.cs @@ -0,0 +1,66 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Entities; + +public class ShoppingList +{ + private readonly List _items = []; + + public Guid ShoppingListId { get; private set; } + + public Guid HouseholdId { get; private set; } + + public string Name { get; private set; } = null!; + + public ShoppingListStatus Status { get; private set; } + + public DateTime CreatedUtc { get; private set; } + + public DateTime? CompletedUtc { get; private set; } + + public string CreatedByUserId { get; private set; } = null!; + + public IReadOnlyCollection Items => _items.AsReadOnly(); + + private ShoppingList() + { + } + + public ShoppingList(Guid householdId, string name, string createdByUserId, DateTime createdUtc) + { + if (householdId == Guid.Empty) + { + throw new ArgumentException("HouseholdId is required.", nameof(householdId)); + } + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Shopping list name is required.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(createdByUserId)) + { + throw new ArgumentException("CreatedByUserId is required.", nameof(createdByUserId)); + } + + ShoppingListId = Guid.NewGuid(); + HouseholdId = householdId; + Name = name.Trim(); + Status = ShoppingListStatus.Active; + CreatedUtc = createdUtc; + CreatedByUserId = createdByUserId; + } + + public ShoppingListItem AddItem(string displayName, decimal? quantity, string? unit, string addedByUserId, DateTime addedUtc) + { + if (Status != ShoppingListStatus.Active) + { + throw new InvalidOperationException("Cannot add items to a list that is not active."); + } + + var sortOrder = _items.Count == 0 ? 0 : _items.Max(i => i.SortOrder) + 1; + var item = new ShoppingListItem(ShoppingListId, displayName, quantity, unit, sortOrder, addedByUserId, addedUtc); + _items.Add(item); + return item; + } +} diff --git a/src/CartWise.Domain/Entities/ShoppingListItem.cs b/src/CartWise.Domain/Entities/ShoppingListItem.cs new file mode 100644 index 0000000..b8a4bce --- /dev/null +++ b/src/CartWise.Domain/Entities/ShoppingListItem.cs @@ -0,0 +1,72 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Entities; + +public class ShoppingListItem +{ + public Guid ShoppingListItemId { get; private set; } + + public Guid ShoppingListId { get; private set; } + + public Guid? GroceryConceptId { get; private set; } + + public string DisplayName { get; private set; } = null!; + + public decimal? Quantity { get; private set; } + + public string? Unit { get; private set; } + + public ShoppingListItemStatus Status { get; private set; } + + public int SortOrder { get; private set; } + + public string AddedByUserId { get; private set; } = null!; + + public DateTime AddedUtc { get; private set; } + + public DateTime? PurchasedUtc { get; private set; } + + public string? Notes { get; private set; } + + public Guid RowVersion { get; private set; } + + private ShoppingListItem() + { + } + + internal ShoppingListItem( + Guid shoppingListId, + string displayName, + decimal? quantity, + string? unit, + int sortOrder, + string addedByUserId, + DateTime addedUtc) + { + if (shoppingListId == Guid.Empty) + { + throw new ArgumentException("ShoppingListId is required.", nameof(shoppingListId)); + } + + if (string.IsNullOrWhiteSpace(displayName)) + { + throw new ArgumentException("DisplayName is required.", nameof(displayName)); + } + + if (string.IsNullOrWhiteSpace(addedByUserId)) + { + throw new ArgumentException("AddedByUserId is required.", nameof(addedByUserId)); + } + + ShoppingListItemId = Guid.NewGuid(); + ShoppingListId = shoppingListId; + DisplayName = displayName.Trim(); + Quantity = quantity; + Unit = unit; + Status = ShoppingListItemStatus.Needed; + SortOrder = sortOrder; + AddedByUserId = addedByUserId; + AddedUtc = addedUtc; + RowVersion = Guid.NewGuid(); + } +} diff --git a/src/CartWise.Domain/Enums/ShoppingListItemStatus.cs b/src/CartWise.Domain/Enums/ShoppingListItemStatus.cs new file mode 100644 index 0000000..a19ceea --- /dev/null +++ b/src/CartWise.Domain/Enums/ShoppingListItemStatus.cs @@ -0,0 +1,10 @@ +namespace CartWise.Domain.Enums; + +public enum ShoppingListItemStatus +{ + Needed = 0, + Purchased = 1, + Skipped = 2, + Unavailable = 3, + Substituted = 4 +} diff --git a/src/CartWise.Domain/Enums/ShoppingListStatus.cs b/src/CartWise.Domain/Enums/ShoppingListStatus.cs new file mode 100644 index 0000000..924b4a0 --- /dev/null +++ b/src/CartWise.Domain/Enums/ShoppingListStatus.cs @@ -0,0 +1,8 @@ +namespace CartWise.Domain.Enums; + +public enum ShoppingListStatus +{ + Active = 0, + Completed = 1, + Archived = 2 +} diff --git a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs index c4721c0..7964418 100644 --- a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs +++ b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs @@ -17,6 +17,14 @@ public class CartWiseDbContext : IdentityDbContext, IApplicatio public DbSet HouseholdMembers => Set(); + public DbSet ProductCategories => Set(); + + public DbSet GroceryConcepts => Set(); + + public DbSet ShoppingLists => Set(); + + public DbSet ShoppingListItems => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/CartWise.Infrastructure/Data/Configurations/GroceryConceptConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/GroceryConceptConfiguration.cs new file mode 100644 index 0000000..ff07bfe --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/GroceryConceptConfiguration.cs @@ -0,0 +1,29 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class GroceryConceptConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.GroceryConceptId); + + builder.Property(c => c.Name) + .HasMaxLength(160) + .IsRequired(); + + builder.Property(c => c.NormalizedName) + .HasMaxLength(160) + .IsRequired(); + + builder.HasIndex(c => c.NormalizedName) + .IsUnique(); + + builder.HasOne() + .WithMany() + .HasForeignKey(c => c.CategoryId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/src/CartWise.Infrastructure/Data/Configurations/ProductCategoryConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/ProductCategoryConfiguration.cs new file mode 100644 index 0000000..6b6308e --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/ProductCategoryConfiguration.cs @@ -0,0 +1,22 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class ProductCategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.ProductCategoryId); + + builder.Property(c => c.Name) + .HasMaxLength(160) + .IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(c => c.ParentCategoryId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/CartWise.Infrastructure/Data/Configurations/ShoppingListConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/ShoppingListConfiguration.cs new file mode 100644 index 0000000..c8a6d6e --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/ShoppingListConfiguration.cs @@ -0,0 +1,33 @@ +using CartWise.Domain.Entities; +using CartWise.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class ShoppingListConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(l => l.ShoppingListId); + + builder.Property(l => l.Name) + .HasMaxLength(160) + .IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(l => l.HouseholdId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(l => l.CreatedByUserId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(l => new { l.HouseholdId, l.Status }); + + builder.Metadata.FindNavigation(nameof(ShoppingList.Items))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/src/CartWise.Infrastructure/Data/Configurations/ShoppingListItemConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/ShoppingListItemConfiguration.cs new file mode 100644 index 0000000..6a6c002 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/ShoppingListItemConfiguration.cs @@ -0,0 +1,44 @@ +using CartWise.Domain.Entities; +using CartWise.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class ShoppingListItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(i => i.ShoppingListItemId); + + builder.Property(i => i.DisplayName) + .HasMaxLength(240) + .IsRequired(); + + builder.Property(i => i.Unit) + .HasMaxLength(32); + + builder.Property(i => i.Notes) + .HasMaxLength(500); + + builder.Property(i => i.RowVersion) + .IsConcurrencyToken(); + + builder.HasOne() + .WithMany(l => l.Items) + .HasForeignKey(i => i.ShoppingListId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(i => i.GroceryConceptId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne() + .WithMany() + .HasForeignKey(i => i.AddedByUserId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(i => new { i.ShoppingListId, i.Status }); + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.Designer.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.Designer.cs new file mode 100644 index 0000000..9dce8bf --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.Designer.cs @@ -0,0 +1,554 @@ +// +using System; +using CartWise.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CartWise.Infrastructure.Data.Migrations +{ + [DbContext(typeof(CartWiseDbContext))] + [Migration("20260810165235_AddShoppingList")] + partial class AddShoppingList + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => + { + b.Property("GroceryConceptId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("GroceryConceptId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("GroceryConcepts"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Property("HouseholdId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("HouseholdId"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("Households"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => + { + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("JoinedUtc") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("HouseholdId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("HouseholdMembers"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => + { + b.Property("ProductCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("ParentCategoryId") + .HasColumnType("TEXT"); + + b.HasKey("ProductCategoryId"); + + b.HasIndex("ParentCategoryId"); + + b.ToTable("ProductCategories"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.Property("ShoppingListId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("ShoppingListId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("HouseholdId", "Status"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b => + { + b.Property("ShoppingListItemId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AddedUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchasedUtc") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ShoppingListId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Unit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("ShoppingListItemId"); + + b.HasIndex("AddedByUserId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("ShoppingListId", "Status"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => + { + b.HasOne("CartWise.Domain.Entities.ProductCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => + { + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany("Members") + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => + { + b.HasOne("CartWise.Domain.Entities.ProductCategory", null) + .WithMany() + .HasForeignKey("ParentCategoryId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("AddedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.ShoppingList", null) + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.cs new file mode 100644 index 0000000..0f157cf --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.cs @@ -0,0 +1,182 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CartWise.Infrastructure.Data.Migrations +{ + /// + public partial class AddShoppingList : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProductCategories", + columns: table => new + { + ProductCategoryId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 160, nullable: false), + ParentCategoryId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ProductCategories", x => x.ProductCategoryId); + table.ForeignKey( + name: "FK_ProductCategories_ProductCategories_ParentCategoryId", + column: x => x.ParentCategoryId, + principalTable: "ProductCategories", + principalColumn: "ProductCategoryId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ShoppingLists", + columns: table => new + { + ShoppingListId = table.Column(type: "TEXT", nullable: false), + HouseholdId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 160, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + CompletedUtc = table.Column(type: "TEXT", nullable: true), + CreatedByUserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ShoppingLists", x => x.ShoppingListId); + table.ForeignKey( + name: "FK_ShoppingLists_AspNetUsers_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ShoppingLists_Households_HouseholdId", + column: x => x.HouseholdId, + principalTable: "Households", + principalColumn: "HouseholdId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GroceryConcepts", + columns: table => new + { + GroceryConceptId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 160, nullable: false), + NormalizedName = table.Column(type: "TEXT", maxLength: 160, nullable: false), + CategoryId = table.Column(type: "TEXT", nullable: true), + CreatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroceryConcepts", x => x.GroceryConceptId); + table.ForeignKey( + name: "FK_GroceryConcepts_ProductCategories_CategoryId", + column: x => x.CategoryId, + principalTable: "ProductCategories", + principalColumn: "ProductCategoryId", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "ShoppingListItems", + columns: table => new + { + ShoppingListItemId = table.Column(type: "TEXT", nullable: false), + ShoppingListId = table.Column(type: "TEXT", nullable: false), + GroceryConceptId = table.Column(type: "TEXT", nullable: true), + DisplayName = table.Column(type: "TEXT", maxLength: 240, nullable: false), + Quantity = table.Column(type: "TEXT", nullable: true), + Unit = table.Column(type: "TEXT", maxLength: 32, nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false), + AddedByUserId = table.Column(type: "TEXT", nullable: false), + AddedUtc = table.Column(type: "TEXT", nullable: false), + PurchasedUtc = table.Column(type: "TEXT", nullable: true), + Notes = table.Column(type: "TEXT", maxLength: 500, nullable: true), + RowVersion = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ShoppingListItems", x => x.ShoppingListItemId); + table.ForeignKey( + name: "FK_ShoppingListItems_AspNetUsers_AddedByUserId", + column: x => x.AddedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ShoppingListItems_GroceryConcepts_GroceryConceptId", + column: x => x.GroceryConceptId, + principalTable: "GroceryConcepts", + principalColumn: "GroceryConceptId", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_ShoppingListItems_ShoppingLists_ShoppingListId", + column: x => x.ShoppingListId, + principalTable: "ShoppingLists", + principalColumn: "ShoppingListId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroceryConcepts_CategoryId", + table: "GroceryConcepts", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_GroceryConcepts_NormalizedName", + table: "GroceryConcepts", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProductCategories_ParentCategoryId", + table: "ProductCategories", + column: "ParentCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_AddedByUserId", + table: "ShoppingListItems", + column: "AddedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_GroceryConceptId", + table: "ShoppingListItems", + column: "GroceryConceptId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_ShoppingListId_Status", + table: "ShoppingListItems", + columns: new[] { "ShoppingListId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingLists_CreatedByUserId", + table: "ShoppingLists", + column: "CreatedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingLists_HouseholdId_Status", + table: "ShoppingLists", + columns: new[] { "HouseholdId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ShoppingListItems"); + + migrationBuilder.DropTable( + name: "GroceryConcepts"); + + migrationBuilder.DropTable( + name: "ShoppingLists"); + + migrationBuilder.DropTable( + name: "ProductCategories"); + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs index fe42641..9420b6c 100644 --- a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs +++ b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs @@ -17,6 +17,38 @@ namespace CartWise.Infrastructure.Data.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => + { + b.Property("GroceryConceptId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("GroceryConceptId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("GroceryConcepts"); + }); + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => { b.Property("HouseholdId") @@ -63,6 +95,122 @@ namespace CartWise.Infrastructure.Data.Migrations b.ToTable("HouseholdMembers"); }); + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => + { + b.Property("ProductCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("ParentCategoryId") + .HasColumnType("TEXT"); + + b.HasKey("ProductCategoryId"); + + b.HasIndex("ParentCategoryId"); + + b.ToTable("ProductCategories"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.Property("ShoppingListId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("ShoppingListId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("HouseholdId", "Status"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b => + { + b.Property("ShoppingListItemId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AddedUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchasedUtc") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ShoppingListId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Unit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("ShoppingListItemId"); + + b.HasIndex("AddedByUserId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("ShoppingListId", "Status"); + + b.ToTable("ShoppingListItems"); + }); + modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b => { b.Property("Id") @@ -262,6 +410,14 @@ namespace CartWise.Infrastructure.Data.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => + { + b.HasOne("CartWise.Domain.Entities.ProductCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + }); + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => { b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) @@ -286,6 +442,49 @@ namespace CartWise.Infrastructure.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => + { + b.HasOne("CartWise.Domain.Entities.ProductCategory", null) + .WithMany() + .HasForeignKey("ParentCategoryId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("AddedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.ShoppingList", null) + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -341,6 +540,11 @@ namespace CartWise.Infrastructure.Data.Migrations { b.Navigation("Members"); }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); #pragma warning restore 612, 618 } } diff --git a/src/CartWise.Web/Controllers/ShoppingListController.cs b/src/CartWise.Web/Controllers/ShoppingListController.cs new file mode 100644 index 0000000..9ebad5e --- /dev/null +++ b/src/CartWise.Web/Controllers/ShoppingListController.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using CartWise.Application.Interfaces; +using CartWise.Infrastructure.Identity; +using CartWise.Web.ViewModels.ShoppingList; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace CartWise.Web.Controllers; + +[Authorize] +[Route("list")] +public class ShoppingListController : Controller +{ + private readonly IShoppingListService _shoppingListService; + private readonly IHouseholdService _householdService; + private readonly UserManager _userManager; + + public ShoppingListController( + IShoppingListService shoppingListService, + IHouseholdService householdService, + UserManager userManager) + { + _shoppingListService = shoppingListService; + _householdService = householdService; + _userManager = userManager; + } + + [HttpGet("")] + public async Task Index() + { + var userId = _userManager.GetUserId(User)!; + var household = await _householdService.GetCurrentHouseholdAsync(userId); + if (household is null) + { + return RedirectToAction("Create", "Household"); + } + + var list = await GetOrCreateActiveListAsync(household.HouseholdId, userId); + return View(ToViewModel(list)); + } + + [HttpPost("items")] + [ValidateAntiForgeryToken] + public async Task AddItem([Bind(Prefix = "NewItem")] AddShoppingListItemViewModel model) + { + var userId = _userManager.GetUserId(User)!; + var household = await _householdService.GetCurrentHouseholdAsync(userId); + if (household is null) + { + return RedirectToAction("Create", "Household"); + } + + var list = await GetOrCreateActiveListAsync(household.HouseholdId, userId); + + if (!ModelState.IsValid) + { + var viewModel = ToViewModel(list); + viewModel.NewItem = model; + return View(nameof(Index), viewModel); + } + + var result = await _shoppingListService.AddItemAsync(list.ShoppingListId, model.DisplayName, model.Quantity, model.Unit, userId); + if (!result.IsSuccess) + { + ModelState.AddModelError(string.Empty, result.Error!); + var viewModel = ToViewModel(list); + viewModel.NewItem = model; + return View(nameof(Index), viewModel); + } + + return RedirectToAction(nameof(Index)); + } + + private async Task GetOrCreateActiveListAsync(Guid householdId, string userId) + { + var list = await _shoppingListService.GetActiveListAsync(householdId); + if (list is not null) + { + return list; + } + + var createResult = await _shoppingListService.CreateListAsync(householdId, "Groceries", userId); + return createResult.Value ?? await _shoppingListService.GetActiveListAsync(householdId) + ?? throw new InvalidOperationException("Unable to resolve the household's active shopping list."); + } + + private static ShoppingListViewModel ToViewModel(Domain.Entities.ShoppingList list) => new() + { + ShoppingListId = list.ShoppingListId, + Name = list.Name, + Items = list.Items + .OrderBy(i => i.SortOrder) + .Select(i => new ShoppingListItemViewModel + { + Id = i.ShoppingListItemId, + DisplayName = i.DisplayName, + QuantityDisplay = FormatQuantity(i.Quantity, i.Unit), + Status = i.Status, + ConcurrencyToken = i.RowVersion + }) + .ToList() + }; + + private static string? FormatQuantity(decimal? quantity, string? unit) + { + if (quantity is null) + { + return unit; + } + + var quantityText = quantity.Value.ToString("0.###", CultureInfo.InvariantCulture); + return unit is null ? quantityText : $"{quantityText} {unit}"; + } +} diff --git a/src/CartWise.Web/ViewModels/ShoppingList/AddShoppingListItemViewModel.cs b/src/CartWise.Web/ViewModels/ShoppingList/AddShoppingListItemViewModel.cs new file mode 100644 index 0000000..1f55bda --- /dev/null +++ b/src/CartWise.Web/ViewModels/ShoppingList/AddShoppingListItemViewModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace CartWise.Web.ViewModels.ShoppingList; + +public class AddShoppingListItemViewModel +{ + [Required] + [Display(Name = "Item")] + [StringLength(240, MinimumLength = 1)] + public string DisplayName { get; set; } = string.Empty; + + [Range(0.001, 100000)] + public decimal? Quantity { get; set; } + + [StringLength(32)] + public string? Unit { get; set; } +} diff --git a/src/CartWise.Web/ViewModels/ShoppingList/ShoppingListViewModel.cs b/src/CartWise.Web/ViewModels/ShoppingList/ShoppingListViewModel.cs new file mode 100644 index 0000000..f5e1ecd --- /dev/null +++ b/src/CartWise.Web/ViewModels/ShoppingList/ShoppingListViewModel.cs @@ -0,0 +1,27 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Web.ViewModels.ShoppingList; + +public class ShoppingListViewModel +{ + public Guid ShoppingListId { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Items { get; set; } = []; + + public AddShoppingListItemViewModel NewItem { get; set; } = new(); +} + +public class ShoppingListItemViewModel +{ + public Guid Id { get; set; } + + public string DisplayName { get; set; } = string.Empty; + + public string? QuantityDisplay { get; set; } + + public ShoppingListItemStatus Status { get; set; } + + public Guid ConcurrencyToken { get; set; } +} diff --git a/src/CartWise.Web/Views/Shared/_Layout.cshtml b/src/CartWise.Web/Views/Shared/_Layout.cshtml index b1c6e93..2a726ca 100644 --- a/src/CartWise.Web/Views/Shared/_Layout.cshtml +++ b/src/CartWise.Web/Views/Shared/_Layout.cshtml @@ -25,6 +25,9 @@ @if (User.Identity?.IsAuthenticated == true) { + diff --git a/src/CartWise.Web/Views/ShoppingList/Index.cshtml b/src/CartWise.Web/Views/ShoppingList/Index.cshtml new file mode 100644 index 0000000..7fb11bf --- /dev/null +++ b/src/CartWise.Web/Views/ShoppingList/Index.cshtml @@ -0,0 +1,46 @@ +@model CartWise.Web.ViewModels.ShoppingList.ShoppingListViewModel +@{ + ViewData["Title"] = Model.Name; +} + +

@Model.Name

+ +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+ +@if (Model.Items.Count == 0) +{ +

No items yet. Add your first item above.

+} +else +{ +
    + @foreach (var item in Model.Items) + { + @await Html.PartialAsync("_ListItem", item) + } +
+} + +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} diff --git a/src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml b/src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml new file mode 100644 index 0000000..9d794cd --- /dev/null +++ b/src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml @@ -0,0 +1,12 @@ +@model CartWise.Web.ViewModels.ShoppingList.ShoppingListItemViewModel + +
  • + + @Model.DisplayName + @if (!string.IsNullOrEmpty(Model.QuantityDisplay)) + { + (@Model.QuantityDisplay) + } + + @Model.Status +
  • diff --git a/tests/CartWise.Application.Tests/ShoppingListServiceTests.cs b/tests/CartWise.Application.Tests/ShoppingListServiceTests.cs new file mode 100644 index 0000000..f7c32c4 --- /dev/null +++ b/tests/CartWise.Application.Tests/ShoppingListServiceTests.cs @@ -0,0 +1,124 @@ +using CartWise.Application.Services; +using CartWise.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Tests; + +public class ShoppingListServiceTests : IDisposable +{ + private readonly CartWiseDbContext _db; + private readonly ShoppingListService _sut; + + public ShoppingListServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _db = new CartWiseDbContext(options); + _sut = new ShoppingListService(_db, TimeProvider.System); + } + + public void Dispose() + { + _db.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task CreateListAsync_CreatesActiveList() + { + var householdId = Guid.NewGuid(); + + var result = await _sut.CreateListAsync(householdId, "Groceries", "user-1"); + + Assert.True(result.IsSuccess); + Assert.Equal("Groceries", result.Value!.Name); + Assert.Equal(householdId, result.Value.HouseholdId); + } + + [Fact] + public async Task CreateListAsync_FailsWhenHouseholdAlreadyHasAnActiveList() + { + var householdId = Guid.NewGuid(); + await _sut.CreateListAsync(householdId, "Groceries", "user-1"); + + var result = await _sut.CreateListAsync(householdId, "Second List", "user-1"); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + } + + [Fact] + public async Task CreateListAsync_FailsWhenNameIsMissing() + { + var result = await _sut.CreateListAsync(Guid.NewGuid(), " ", "user-1"); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task GetActiveListAsync_ReturnsNullWhenHouseholdHasNoList() + { + var list = await _sut.GetActiveListAsync(Guid.NewGuid()); + + Assert.Null(list); + } + + [Fact] + public async Task GetActiveListAsync_ReturnsTheActiveList() + { + var householdId = Guid.NewGuid(); + var created = await _sut.CreateListAsync(householdId, "Groceries", "user-1"); + + var list = await _sut.GetActiveListAsync(householdId); + + Assert.NotNull(list); + Assert.Equal(created.Value!.ShoppingListId, list!.ShoppingListId); + } + + [Fact] + public async Task GetActiveListAsync_DoesNotReturnAnotherHouseholdsList() + { + var householdA = Guid.NewGuid(); + var householdB = Guid.NewGuid(); + await _sut.CreateListAsync(householdA, "Household A's List", "user-1"); + + var list = await _sut.GetActiveListAsync(householdB); + + Assert.Null(list); + } + + [Fact] + public async Task AddItemAsync_AddsItemAndPersistsIt() + { + var created = await _sut.CreateListAsync(Guid.NewGuid(), "Groceries", "user-1"); + + var result = await _sut.AddItemAsync(created.Value!.ShoppingListId, "Milk", 2m, "gal", "user-1"); + + Assert.True(result.IsSuccess); + Assert.Equal("Milk", result.Value!.DisplayName); + + var reloaded = await _sut.GetActiveListAsync(created.Value.HouseholdId); + Assert.Single(reloaded!.Items); + Assert.Equal("Milk", reloaded.Items.Single().DisplayName); + } + + [Fact] + public async Task AddItemAsync_FailsWhenListDoesNotExist() + { + var result = await _sut.AddItemAsync(Guid.NewGuid(), "Milk", null, null, "user-1"); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task AddItemAsync_FailsWhenDisplayNameIsMissing() + { + var created = await _sut.CreateListAsync(Guid.NewGuid(), "Groceries", "user-1"); + + var result = await _sut.AddItemAsync(created.Value!.ShoppingListId, " ", null, null, "user-1"); + + Assert.False(result.IsSuccess); + } +} diff --git a/tests/CartWise.Domain.Tests/ShoppingListTests.cs b/tests/CartWise.Domain.Tests/ShoppingListTests.cs new file mode 100644 index 0000000..7f7b3b9 --- /dev/null +++ b/tests/CartWise.Domain.Tests/ShoppingListTests.cs @@ -0,0 +1,81 @@ +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Tests; + +public class ShoppingListTests +{ + private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Constructor_CreatesActiveListWithNoItems() + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + + Assert.Equal(ShoppingListStatus.Active, list.Status); + Assert.Empty(list.Items); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Constructor_ThrowsWhenNameIsMissing(string? name) + { + Assert.Throws(() => new ShoppingList(Guid.NewGuid(), name!, "user-1", Now)); + } + + [Fact] + public void Constructor_ThrowsWhenHouseholdIdIsEmpty() + { + Assert.Throws(() => new ShoppingList(Guid.Empty, "Groceries", "user-1", Now)); + } + + [Fact] + public void AddItem_AddsItemWithNeededStatusAndZeroSortOrder() + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + + var item = list.AddItem("Milk", 2m, "gal", "user-1", Now); + + Assert.Single(list.Items); + Assert.Equal("Milk", item.DisplayName); + Assert.Equal(ShoppingListItemStatus.Needed, item.Status); + Assert.Equal(0, item.SortOrder); + Assert.Equal(list.ShoppingListId, item.ShoppingListId); + } + + [Fact] + public void AddItem_AssignsIncrementingSortOrder() + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + + var first = list.AddItem("Milk", null, null, "user-1", Now); + var second = list.AddItem("Bread", null, null, "user-1", Now); + + Assert.Equal(0, first.SortOrder); + Assert.Equal(1, second.SortOrder); + } + + [Fact] + public void AddItem_DoesNotRequireQuantityOrUnit() + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + + var item = list.AddItem("Milk", null, null, "user-1", Now); + + Assert.Null(item.Quantity); + Assert.Null(item.Unit); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void AddItem_ThrowsWhenDisplayNameIsMissing(string? displayName) + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + + Assert.Throws(() => list.AddItem(displayName!, null, null, "user-1", Now)); + } +} diff --git a/tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs b/tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs index 64d6321..b6a8821 100644 --- a/tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs +++ b/tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc.Testing; namespace CartWise.Web.Tests; @@ -30,11 +29,11 @@ public class HouseholdAccessControlTests : IClassFixture - { - ["__RequestVerificationToken"] = token, - ["DisplayName"] = displayName, - ["Email"] = email, - ["Password"] = "Sup3rSecret!23", - ["ConfirmPassword"] = "Sup3rSecret!23" - }; - - var response = await client.PostAsync("/Account/Register", new FormUrlEncodedContent(form)); - response.EnsureSuccessStatusCode(); - } - - private static async Task CreateHouseholdAsync(HttpClient client, string name) - { - var html = await client.GetStringAsync("/household/create"); - var token = ExtractAntiForgeryToken(html); - - var form = new Dictionary - { - ["__RequestVerificationToken"] = token, - ["Name"] = name - }; - - var response = await client.PostAsync("/household/create", new FormUrlEncodedContent(form)); - response.EnsureSuccessStatusCode(); - } - - private static string ExtractAntiForgeryToken(string html) - { - var match = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\""); - return match.Success ? match.Groups[1].Value : throw new InvalidOperationException("Anti-forgery token not found."); - } } diff --git a/tests/CartWise.Web.Tests/ShoppingListAccessControlTests.cs b/tests/CartWise.Web.Tests/ShoppingListAccessControlTests.cs new file mode 100644 index 0000000..8cdcda7 --- /dev/null +++ b/tests/CartWise.Web.Tests/ShoppingListAccessControlTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace CartWise.Web.Tests; + +public class ShoppingListAccessControlTests : IClassFixture +{ + private readonly CartWiseWebApplicationFactory _factory; + + public ShoppingListAccessControlTests(CartWiseWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task AnonymousUser_IsRedirectedToLoginWhenRequestingList() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var response = await client.GetAsync("/list"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Contains("/Account/Login", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task Index_AutoCreatesDefaultListForNewHousehold() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "listuser1@example.com", "List User"); + await WebTestHelpers.CreateHouseholdAsync(client, "List Household"); + + var html = await client.GetStringAsync("/list"); + + HtmlAssert.Contains("Groceries", html); + } + + [Fact] + public async Task AddItem_AppearsOnActiveList() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "listuser2@example.com", "List User 2"); + await WebTestHelpers.CreateHouseholdAsync(client, "Another Household"); + + var listHtml = await client.GetStringAsync("/list"); + var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); + + var form = new Dictionary + { + ["__RequestVerificationToken"] = token, + ["NewItem.DisplayName"] = "Milk", + ["NewItem.Quantity"] = "2", + ["NewItem.Unit"] = "gal" + }; + + var postResponse = await client.PostAsync("/list/items", new FormUrlEncodedContent(form)); + postResponse.EnsureSuccessStatusCode(); + + var updatedHtml = await client.GetStringAsync("/list"); + HtmlAssert.Contains("Milk", updatedHtml); + HtmlAssert.Contains("2 gal", updatedHtml); + } + + [Fact] + public async Task AddItem_WithMissingDisplayName_ShowsValidationErrorAndDoesNotAddItem() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "listuser3@example.com", "List User 3"); + await WebTestHelpers.CreateHouseholdAsync(client, "Third Household"); + + var listHtml = await client.GetStringAsync("/list"); + var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); + + var form = new Dictionary + { + ["__RequestVerificationToken"] = token, + ["NewItem.DisplayName"] = string.Empty + }; + + var postResponse = await client.PostAsync("/list/items", new FormUrlEncodedContent(form)); + var body = await postResponse.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, postResponse.StatusCode); + Assert.Contains("field-validation-error", body); + } + + [Fact] + public async Task EachHousehold_OnlySeesItsOwnListItems() + { + var clientA = _factory.CreateClient(); + var clientB = _factory.CreateClient(); + + await WebTestHelpers.RegisterAsync(clientA, "listalice@example.com", "List Alice"); + await WebTestHelpers.RegisterAsync(clientB, "listbob@example.com", "List Bob"); + await WebTestHelpers.CreateHouseholdAsync(clientA, "Alice's List Household"); + await WebTestHelpers.CreateHouseholdAsync(clientB, "Bob's List Household"); + + var listHtmlA = await clientA.GetStringAsync("/list"); + var tokenA = WebTestHelpers.ExtractAntiForgeryToken(listHtmlA); + await clientA.PostAsync("/list/items", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = tokenA, + ["NewItem.DisplayName"] = "Alice Item" + })); + + var listHtmlB = await clientB.GetStringAsync("/list"); + var tokenB = WebTestHelpers.ExtractAntiForgeryToken(listHtmlB); + await clientB.PostAsync("/list/items", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = tokenB, + ["NewItem.DisplayName"] = "Bob Item" + })); + + var finalA = await clientA.GetStringAsync("/list"); + var finalB = await clientB.GetStringAsync("/list"); + + HtmlAssert.Contains("Alice Item", finalA); + HtmlAssert.DoesNotContain("Bob Item", finalA); + + HtmlAssert.Contains("Bob Item", finalB); + HtmlAssert.DoesNotContain("Alice Item", finalB); + } +} diff --git a/tests/CartWise.Web.Tests/WebTestHelpers.cs b/tests/CartWise.Web.Tests/WebTestHelpers.cs new file mode 100644 index 0000000..839f4b3 --- /dev/null +++ b/tests/CartWise.Web.Tests/WebTestHelpers.cs @@ -0,0 +1,45 @@ +using System.Text.RegularExpressions; + +namespace CartWise.Web.Tests; + +internal static class WebTestHelpers +{ + public static async Task RegisterAsync(HttpClient client, string email, string displayName, string password = "Sup3rSecret!23") + { + var html = await client.GetStringAsync("/Account/Register"); + var token = ExtractAntiForgeryToken(html); + + var form = new Dictionary + { + ["__RequestVerificationToken"] = token, + ["DisplayName"] = displayName, + ["Email"] = email, + ["Password"] = password, + ["ConfirmPassword"] = password + }; + + var response = await client.PostAsync("/Account/Register", new FormUrlEncodedContent(form)); + response.EnsureSuccessStatusCode(); + } + + public static async Task CreateHouseholdAsync(HttpClient client, string name) + { + var html = await client.GetStringAsync("/household/create"); + var token = ExtractAntiForgeryToken(html); + + var form = new Dictionary + { + ["__RequestVerificationToken"] = token, + ["Name"] = name + }; + + var response = await client.PostAsync("/household/create", new FormUrlEncodedContent(form)); + response.EnsureSuccessStatusCode(); + } + + public static string ExtractAntiForgeryToken(string html) + { + var match = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\""); + return match.Success ? match.Groups[1].Value : throw new InvalidOperationException("Anti-forgery token not found."); + } +}