| @@ -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" | |||
| ] | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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<ShoppingList>`, 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 | |||
| @@ -10,6 +10,7 @@ public static class ApplicationServiceCollectionExtensions | |||
| { | |||
| services.AddSingleton(TimeProvider.System); | |||
| services.AddScoped<IHouseholdService, HouseholdService>(); | |||
| services.AddScoped<IShoppingListService, ShoppingListService>(); | |||
| return services; | |||
| } | |||
| @@ -9,5 +9,9 @@ public interface IApplicationDbContext | |||
| DbSet<HouseholdMember> HouseholdMembers { get; } | |||
| DbSet<ShoppingList> ShoppingLists { get; } | |||
| DbSet<ShoppingListItem> ShoppingListItems { get; } | |||
| Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -0,0 +1,19 @@ | |||
| using CartWise.Application.Results; | |||
| using CartWise.Domain.Entities; | |||
| namespace CartWise.Application.Interfaces; | |||
| public interface IShoppingListService | |||
| { | |||
| Task<ShoppingList?> GetActiveListAsync(Guid householdId, CancellationToken cancellationToken = default); | |||
| Task<Result<ShoppingList>> CreateListAsync(Guid householdId, string name, string createdByUserId, CancellationToken cancellationToken = default); | |||
| Task<Result<ShoppingListItem>> AddItemAsync( | |||
| Guid shoppingListId, | |||
| string displayName, | |||
| decimal? quantity, | |||
| string? unit, | |||
| string addedByUserId, | |||
| CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -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<ShoppingList?> 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<Result<ShoppingList>> 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<ShoppingList>.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<ShoppingList>.Failure(ex.Message); | |||
| } | |||
| _db.ShoppingLists.Add(list); | |||
| await _db.SaveChangesAsync(cancellationToken); | |||
| return Result<ShoppingList>.Success(list); | |||
| } | |||
| public async Task<Result<ShoppingListItem>> 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<ShoppingListItem>.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<ShoppingListItem>.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<ShoppingListItem>.Success(item); | |||
| } | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -0,0 +1,66 @@ | |||
| using CartWise.Domain.Enums; | |||
| namespace CartWise.Domain.Entities; | |||
| public class ShoppingList | |||
| { | |||
| private readonly List<ShoppingListItem> _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<ShoppingListItem> 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; | |||
| } | |||
| } | |||
| @@ -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(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| namespace CartWise.Domain.Enums; | |||
| public enum ShoppingListItemStatus | |||
| { | |||
| Needed = 0, | |||
| Purchased = 1, | |||
| Skipped = 2, | |||
| Unavailable = 3, | |||
| Substituted = 4 | |||
| } | |||
| @@ -0,0 +1,8 @@ | |||
| namespace CartWise.Domain.Enums; | |||
| public enum ShoppingListStatus | |||
| { | |||
| Active = 0, | |||
| Completed = 1, | |||
| Archived = 2 | |||
| } | |||
| @@ -17,6 +17,14 @@ public class CartWiseDbContext : IdentityDbContext<ApplicationUser>, IApplicatio | |||
| public DbSet<HouseholdMember> HouseholdMembers => Set<HouseholdMember>(); | |||
| public DbSet<ProductCategory> ProductCategories => Set<ProductCategory>(); | |||
| public DbSet<GroceryConcept> GroceryConcepts => Set<GroceryConcept>(); | |||
| public DbSet<ShoppingList> ShoppingLists => Set<ShoppingList>(); | |||
| public DbSet<ShoppingListItem> ShoppingListItems => Set<ShoppingListItem>(); | |||
| protected override void OnModelCreating(ModelBuilder builder) | |||
| { | |||
| base.OnModelCreating(builder); | |||
| @@ -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<GroceryConcept> | |||
| { | |||
| public void Configure(EntityTypeBuilder<GroceryConcept> 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<ProductCategory>() | |||
| .WithMany() | |||
| .HasForeignKey(c => c.CategoryId) | |||
| .OnDelete(DeleteBehavior.SetNull); | |||
| } | |||
| } | |||
| @@ -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<ProductCategory> | |||
| { | |||
| public void Configure(EntityTypeBuilder<ProductCategory> builder) | |||
| { | |||
| builder.HasKey(c => c.ProductCategoryId); | |||
| builder.Property(c => c.Name) | |||
| .HasMaxLength(160) | |||
| .IsRequired(); | |||
| builder.HasOne<ProductCategory>() | |||
| .WithMany() | |||
| .HasForeignKey(c => c.ParentCategoryId) | |||
| .OnDelete(DeleteBehavior.Restrict); | |||
| } | |||
| } | |||
| @@ -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<ShoppingList> | |||
| { | |||
| public void Configure(EntityTypeBuilder<ShoppingList> builder) | |||
| { | |||
| builder.HasKey(l => l.ShoppingListId); | |||
| builder.Property(l => l.Name) | |||
| .HasMaxLength(160) | |||
| .IsRequired(); | |||
| builder.HasOne<Household>() | |||
| .WithMany() | |||
| .HasForeignKey(l => l.HouseholdId) | |||
| .OnDelete(DeleteBehavior.Cascade); | |||
| builder.HasOne<ApplicationUser>() | |||
| .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); | |||
| } | |||
| } | |||
| @@ -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<ShoppingListItem> | |||
| { | |||
| public void Configure(EntityTypeBuilder<ShoppingListItem> 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<ShoppingList>() | |||
| .WithMany(l => l.Items) | |||
| .HasForeignKey(i => i.ShoppingListId) | |||
| .OnDelete(DeleteBehavior.Cascade); | |||
| builder.HasOne<GroceryConcept>() | |||
| .WithMany() | |||
| .HasForeignKey(i => i.GroceryConceptId) | |||
| .OnDelete(DeleteBehavior.SetNull); | |||
| builder.HasOne<ApplicationUser>() | |||
| .WithMany() | |||
| .HasForeignKey(i => i.AddedByUserId) | |||
| .OnDelete(DeleteBehavior.Restrict); | |||
| builder.HasIndex(i => new { i.ShoppingListId, i.Status }); | |||
| } | |||
| } | |||
| @@ -0,0 +1,554 @@ | |||
| // <auto-generated /> | |||
| 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 | |||
| { | |||
| /// <inheritdoc /> | |||
| 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<Guid>("GroceryConceptId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid?>("CategoryId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("CreatedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .IsRequired() | |||
| .HasMaxLength(160) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("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<Guid>("HouseholdId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("CreatedByUserId") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("CreatedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .IsRequired() | |||
| .HasMaxLength(120) | |||
| .HasColumnType("TEXT"); | |||
| b.HasKey("HouseholdId"); | |||
| b.HasIndex("CreatedByUserId"); | |||
| b.ToTable("Households"); | |||
| }); | |||
| modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => | |||
| { | |||
| b.Property<Guid>("HouseholdId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("UserId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("JoinedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<int>("Role") | |||
| .HasColumnType("INTEGER"); | |||
| b.HasKey("HouseholdId", "UserId"); | |||
| b.HasIndex("UserId"); | |||
| b.ToTable("HouseholdMembers"); | |||
| }); | |||
| modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => | |||
| { | |||
| b.Property<Guid>("ProductCategoryId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .IsRequired() | |||
| .HasMaxLength(160) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid?>("ParentCategoryId") | |||
| .HasColumnType("TEXT"); | |||
| b.HasKey("ProductCategoryId"); | |||
| b.HasIndex("ParentCategoryId"); | |||
| b.ToTable("ProductCategories"); | |||
| }); | |||
| modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => | |||
| { | |||
| b.Property<Guid>("ShoppingListId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime?>("CompletedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("CreatedByUserId") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("CreatedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid>("HouseholdId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .IsRequired() | |||
| .HasMaxLength(160) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<int>("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<Guid>("ShoppingListItemId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("AddedByUserId") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("AddedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("DisplayName") | |||
| .IsRequired() | |||
| .HasMaxLength(240) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid?>("GroceryConceptId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Notes") | |||
| .HasMaxLength(500) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime?>("PurchasedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<decimal?>("Quantity") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid>("RowVersion") | |||
| .IsConcurrencyToken() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid>("ShoppingListId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<int>("SortOrder") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<int>("Status") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<string>("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<string>("Id") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<int>("AccessFailedCount") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<string>("ConcurrencyStamp") | |||
| .IsConcurrencyToken() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("CreatedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("DisplayName") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Email") | |||
| .HasMaxLength(256) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<bool>("EmailConfirmed") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<bool>("LockoutEnabled") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<DateTimeOffset?>("LockoutEnd") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("NormalizedEmail") | |||
| .HasMaxLength(256) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("NormalizedUserName") | |||
| .HasMaxLength(256) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("PasswordHash") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("PhoneNumber") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<bool>("PhoneNumberConfirmed") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<string>("SecurityStamp") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<bool>("TwoFactorEnabled") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<string>("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<string>("Id") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("ConcurrencyStamp") | |||
| .IsConcurrencyToken() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .HasMaxLength(256) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("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<string>", b => | |||
| { | |||
| b.Property<int>("Id") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<string>("ClaimType") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("ClaimValue") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("RoleId") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.HasKey("Id"); | |||
| b.HasIndex("RoleId"); | |||
| b.ToTable("AspNetRoleClaims", (string)null); | |||
| }); | |||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => | |||
| { | |||
| b.Property<int>("Id") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<string>("ClaimType") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("ClaimValue") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("UserId") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.HasKey("Id"); | |||
| b.HasIndex("UserId"); | |||
| b.ToTable("AspNetUserClaims", (string)null); | |||
| }); | |||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => | |||
| { | |||
| b.Property<string>("LoginProvider") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("ProviderKey") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("ProviderDisplayName") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("UserId") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.HasKey("LoginProvider", "ProviderKey"); | |||
| b.HasIndex("UserId"); | |||
| b.ToTable("AspNetUserLogins", (string)null); | |||
| }); | |||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => | |||
| { | |||
| b.Property<string>("UserId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("RoleId") | |||
| .HasColumnType("TEXT"); | |||
| b.HasKey("UserId", "RoleId"); | |||
| b.HasIndex("RoleId"); | |||
| b.ToTable("AspNetUserRoles", (string)null); | |||
| }); | |||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => | |||
| { | |||
| b.Property<string>("UserId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("LoginProvider") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("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<string>", b => | |||
| { | |||
| b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) | |||
| .WithMany() | |||
| .HasForeignKey("RoleId") | |||
| .OnDelete(DeleteBehavior.Cascade) | |||
| .IsRequired(); | |||
| }); | |||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => | |||
| { | |||
| b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) | |||
| .WithMany() | |||
| .HasForeignKey("UserId") | |||
| .OnDelete(DeleteBehavior.Cascade) | |||
| .IsRequired(); | |||
| }); | |||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => | |||
| { | |||
| b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) | |||
| .WithMany() | |||
| .HasForeignKey("UserId") | |||
| .OnDelete(DeleteBehavior.Cascade) | |||
| .IsRequired(); | |||
| }); | |||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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 | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,182 @@ | |||
| using System; | |||
| using Microsoft.EntityFrameworkCore.Migrations; | |||
| #nullable disable | |||
| namespace CartWise.Infrastructure.Data.Migrations | |||
| { | |||
| /// <inheritdoc /> | |||
| public partial class AddShoppingList : Migration | |||
| { | |||
| /// <inheritdoc /> | |||
| protected override void Up(MigrationBuilder migrationBuilder) | |||
| { | |||
| migrationBuilder.CreateTable( | |||
| name: "ProductCategories", | |||
| columns: table => new | |||
| { | |||
| ProductCategoryId = table.Column<Guid>(type: "TEXT", nullable: false), | |||
| Name = table.Column<string>(type: "TEXT", maxLength: 160, nullable: false), | |||
| ParentCategoryId = table.Column<Guid>(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<Guid>(type: "TEXT", nullable: false), | |||
| HouseholdId = table.Column<Guid>(type: "TEXT", nullable: false), | |||
| Name = table.Column<string>(type: "TEXT", maxLength: 160, nullable: false), | |||
| Status = table.Column<int>(type: "INTEGER", nullable: false), | |||
| CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false), | |||
| CompletedUtc = table.Column<DateTime>(type: "TEXT", nullable: true), | |||
| CreatedByUserId = table.Column<string>(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<Guid>(type: "TEXT", nullable: false), | |||
| Name = table.Column<string>(type: "TEXT", maxLength: 160, nullable: false), | |||
| NormalizedName = table.Column<string>(type: "TEXT", maxLength: 160, nullable: false), | |||
| CategoryId = table.Column<Guid>(type: "TEXT", nullable: true), | |||
| CreatedUtc = table.Column<DateTime>(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<Guid>(type: "TEXT", nullable: false), | |||
| ShoppingListId = table.Column<Guid>(type: "TEXT", nullable: false), | |||
| GroceryConceptId = table.Column<Guid>(type: "TEXT", nullable: true), | |||
| DisplayName = table.Column<string>(type: "TEXT", maxLength: 240, nullable: false), | |||
| Quantity = table.Column<decimal>(type: "TEXT", nullable: true), | |||
| Unit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: true), | |||
| Status = table.Column<int>(type: "INTEGER", nullable: false), | |||
| SortOrder = table.Column<int>(type: "INTEGER", nullable: false), | |||
| AddedByUserId = table.Column<string>(type: "TEXT", nullable: false), | |||
| AddedUtc = table.Column<DateTime>(type: "TEXT", nullable: false), | |||
| PurchasedUtc = table.Column<DateTime>(type: "TEXT", nullable: true), | |||
| Notes = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true), | |||
| RowVersion = table.Column<Guid>(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" }); | |||
| } | |||
| /// <inheritdoc /> | |||
| protected override void Down(MigrationBuilder migrationBuilder) | |||
| { | |||
| migrationBuilder.DropTable( | |||
| name: "ShoppingListItems"); | |||
| migrationBuilder.DropTable( | |||
| name: "GroceryConcepts"); | |||
| migrationBuilder.DropTable( | |||
| name: "ShoppingLists"); | |||
| migrationBuilder.DropTable( | |||
| name: "ProductCategories"); | |||
| } | |||
| } | |||
| } | |||
| @@ -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<Guid>("GroceryConceptId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid?>("CategoryId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("CreatedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .IsRequired() | |||
| .HasMaxLength(160) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("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<Guid>("HouseholdId") | |||
| @@ -63,6 +95,122 @@ namespace CartWise.Infrastructure.Data.Migrations | |||
| b.ToTable("HouseholdMembers"); | |||
| }); | |||
| modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => | |||
| { | |||
| b.Property<Guid>("ProductCategoryId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .IsRequired() | |||
| .HasMaxLength(160) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid?>("ParentCategoryId") | |||
| .HasColumnType("TEXT"); | |||
| b.HasKey("ProductCategoryId"); | |||
| b.HasIndex("ParentCategoryId"); | |||
| b.ToTable("ProductCategories"); | |||
| }); | |||
| modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => | |||
| { | |||
| b.Property<Guid>("ShoppingListId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime?>("CompletedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("CreatedByUserId") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("CreatedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid>("HouseholdId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Name") | |||
| .IsRequired() | |||
| .HasMaxLength(160) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<int>("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<Guid>("ShoppingListItemId") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("AddedByUserId") | |||
| .IsRequired() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime>("AddedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("DisplayName") | |||
| .IsRequired() | |||
| .HasMaxLength(240) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid?>("GroceryConceptId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<string>("Notes") | |||
| .HasMaxLength(500) | |||
| .HasColumnType("TEXT"); | |||
| b.Property<DateTime?>("PurchasedUtc") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<decimal?>("Quantity") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid>("RowVersion") | |||
| .IsConcurrencyToken() | |||
| .HasColumnType("TEXT"); | |||
| b.Property<Guid>("ShoppingListId") | |||
| .HasColumnType("TEXT"); | |||
| b.Property<int>("SortOrder") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<int>("Status") | |||
| .HasColumnType("INTEGER"); | |||
| b.Property<string>("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<string>("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<string>", 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 | |||
| } | |||
| } | |||
| @@ -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<ApplicationUser> _userManager; | |||
| public ShoppingListController( | |||
| IShoppingListService shoppingListService, | |||
| IHouseholdService householdService, | |||
| UserManager<ApplicationUser> userManager) | |||
| { | |||
| _shoppingListService = shoppingListService; | |||
| _householdService = householdService; | |||
| _userManager = userManager; | |||
| } | |||
| [HttpGet("")] | |||
| public async Task<IActionResult> 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<IActionResult> 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<Domain.Entities.ShoppingList> 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}"; | |||
| } | |||
| } | |||
| @@ -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; } | |||
| } | |||
| @@ -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<ShoppingListItemViewModel> 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; } | |||
| } | |||
| @@ -25,6 +25,9 @@ | |||
| </li> | |||
| @if (User.Identity?.IsAuthenticated == true) | |||
| { | |||
| <li class="nav-item"> | |||
| <a class="nav-link text-dark" asp-controller="ShoppingList" asp-action="Index">List</a> | |||
| </li> | |||
| <li class="nav-item"> | |||
| <a class="nav-link text-dark" asp-controller="Household" asp-action="Index">Household</a> | |||
| </li> | |||
| @@ -0,0 +1,46 @@ | |||
| @model CartWise.Web.ViewModels.ShoppingList.ShoppingListViewModel | |||
| @{ | |||
| ViewData["Title"] = Model.Name; | |||
| } | |||
| <h1>@Model.Name</h1> | |||
| <form asp-controller="ShoppingList" asp-action="AddItem" method="post" class="row g-2 mb-4"> | |||
| <div asp-validation-summary="ModelOnly" class="text-danger"></div> | |||
| <div class="col-12 col-sm-5"> | |||
| <label asp-for="NewItem.DisplayName" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.DisplayName" class="form-control" placeholder="Add an item (e.g. Milk)" autofocus /> | |||
| <span asp-validation-for="NewItem.DisplayName" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-6 col-sm-3"> | |||
| <label asp-for="NewItem.Quantity" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.Quantity" class="form-control" placeholder="Qty" /> | |||
| <span asp-validation-for="NewItem.Quantity" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-6 col-sm-2"> | |||
| <label asp-for="NewItem.Unit" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.Unit" class="form-control" placeholder="Unit" /> | |||
| <span asp-validation-for="NewItem.Unit" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-12 col-sm-2"> | |||
| <button type="submit" class="btn btn-primary w-100">Add</button> | |||
| </div> | |||
| </form> | |||
| @if (Model.Items.Count == 0) | |||
| { | |||
| <p class="text-muted">No items yet. Add your first item above.</p> | |||
| } | |||
| else | |||
| { | |||
| <ul class="list-group" id="shopping-list-items"> | |||
| @foreach (var item in Model.Items) | |||
| { | |||
| @await Html.PartialAsync("_ListItem", item) | |||
| } | |||
| </ul> | |||
| } | |||
| @section Scripts { | |||
| @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| @model CartWise.Web.ViewModels.ShoppingList.ShoppingListItemViewModel | |||
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |||
| <span> | |||
| @Model.DisplayName | |||
| @if (!string.IsNullOrEmpty(Model.QuantityDisplay)) | |||
| { | |||
| <span class="text-muted">(@Model.QuantityDisplay)</span> | |||
| } | |||
| </span> | |||
| <span class="badge bg-secondary">@Model.Status</span> | |||
| </li> | |||
| @@ -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<CartWiseDbContext>() | |||
| .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); | |||
| } | |||
| } | |||
| @@ -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<ArgumentException>(() => new ShoppingList(Guid.NewGuid(), name!, "user-1", Now)); | |||
| } | |||
| [Fact] | |||
| public void Constructor_ThrowsWhenHouseholdIdIsEmpty() | |||
| { | |||
| Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => list.AddItem(displayName!, null, null, "user-1", Now)); | |||
| } | |||
| } | |||
| @@ -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<CartWiseWebApplicationF | |||
| var clientA = _factory.CreateClient(); | |||
| var clientB = _factory.CreateClient(); | |||
| await RegisterAsync(clientA, "alice2@example.com", "Alice"); | |||
| await RegisterAsync(clientB, "bob2@example.com", "Bob"); | |||
| await WebTestHelpers.RegisterAsync(clientA, "alice2@example.com", "Alice"); | |||
| await WebTestHelpers.RegisterAsync(clientB, "bob2@example.com", "Bob"); | |||
| await CreateHouseholdAsync(clientA, "Alice's Household"); | |||
| await CreateHouseholdAsync(clientB, "Bob's Household"); | |||
| await WebTestHelpers.CreateHouseholdAsync(clientA, "Alice's Household"); | |||
| await WebTestHelpers.CreateHouseholdAsync(clientB, "Bob's Household"); | |||
| var householdPageA = await clientA.GetStringAsync("/household"); | |||
| var householdPageB = await clientB.GetStringAsync("/household"); | |||
| @@ -45,43 +44,4 @@ public class HouseholdAccessControlTests : IClassFixture<CartWiseWebApplicationF | |||
| HtmlAssert.Contains("Bob's Household", householdPageB); | |||
| HtmlAssert.DoesNotContain("Alice's Household", householdPageB); | |||
| } | |||
| private static async Task RegisterAsync(HttpClient client, string email, string displayName) | |||
| { | |||
| var html = await client.GetStringAsync("/Account/Register"); | |||
| var token = ExtractAntiForgeryToken(html); | |||
| var form = new Dictionary<string, string> | |||
| { | |||
| ["__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<string, string> | |||
| { | |||
| ["__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."); | |||
| } | |||
| } | |||
| @@ -0,0 +1,123 @@ | |||
| using System.Net; | |||
| using Microsoft.AspNetCore.Mvc.Testing; | |||
| namespace CartWise.Web.Tests; | |||
| public class ShoppingListAccessControlTests : IClassFixture<CartWiseWebApplicationFactory> | |||
| { | |||
| 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<string, string> | |||
| { | |||
| ["__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<string, string> | |||
| { | |||
| ["__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<string, string> | |||
| { | |||
| ["__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<string, string> | |||
| { | |||
| ["__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); | |||
| } | |||
| } | |||
| @@ -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<string, string> | |||
| { | |||
| ["__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<string, string> | |||
| { | |||
| ["__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."); | |||
| } | |||
| } | |||
Powered by TurnKey Linux.