Daniel Covington 1 tydzień temu
rodzic
commit
17181a6a35
32 zmienionych plików z 2042 dodań i 64 usunięć
  1. +5
    -1
      .claude/settings.json
  2. +4
    -0
      .editorconfig
  3. +31
    -19
      docs/scrum-backlog.md
  4. +1
    -0
      src/CartWise.Application/ApplicationServiceCollectionExtensions.cs
  5. +4
    -0
      src/CartWise.Application/Interfaces/IApplicationDbContext.cs
  6. +19
    -0
      src/CartWise.Application/Interfaces/IShoppingListService.cs
  7. +91
    -0
      src/CartWise.Application/Services/ShoppingListService.cs
  8. +32
    -0
      src/CartWise.Domain/Entities/GroceryConcept.cs
  9. +26
    -0
      src/CartWise.Domain/Entities/ProductCategory.cs
  10. +66
    -0
      src/CartWise.Domain/Entities/ShoppingList.cs
  11. +72
    -0
      src/CartWise.Domain/Entities/ShoppingListItem.cs
  12. +10
    -0
      src/CartWise.Domain/Enums/ShoppingListItemStatus.cs
  13. +8
    -0
      src/CartWise.Domain/Enums/ShoppingListStatus.cs
  14. +8
    -0
      src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
  15. +29
    -0
      src/CartWise.Infrastructure/Data/Configurations/GroceryConceptConfiguration.cs
  16. +22
    -0
      src/CartWise.Infrastructure/Data/Configurations/ProductCategoryConfiguration.cs
  17. +33
    -0
      src/CartWise.Infrastructure/Data/Configurations/ShoppingListConfiguration.cs
  18. +44
    -0
      src/CartWise.Infrastructure/Data/Configurations/ShoppingListItemConfiguration.cs
  19. +554
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.Designer.cs
  20. +182
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.cs
  21. +204
    -0
      src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
  22. +115
    -0
      src/CartWise.Web/Controllers/ShoppingListController.cs
  23. +17
    -0
      src/CartWise.Web/ViewModels/ShoppingList/AddShoppingListItemViewModel.cs
  24. +27
    -0
      src/CartWise.Web/ViewModels/ShoppingList/ShoppingListViewModel.cs
  25. +3
    -0
      src/CartWise.Web/Views/Shared/_Layout.cshtml
  26. +46
    -0
      src/CartWise.Web/Views/ShoppingList/Index.cshtml
  27. +12
    -0
      src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml
  28. +124
    -0
      tests/CartWise.Application.Tests/ShoppingListServiceTests.cs
  29. +81
    -0
      tests/CartWise.Domain.Tests/ShoppingListTests.cs
  30. +4
    -44
      tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs
  31. +123
    -0
      tests/CartWise.Web.Tests/ShoppingListAccessControlTests.cs
  32. +45
    -0
      tests/CartWise.Web.Tests/WebTestHelpers.cs

+ 5
- 1
.claude/settings.json Wyświetl plik

@@ -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"
]
}
}

+ 4
- 0
.editorconfig Wyświetl plik

@@ -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

+ 31
- 19
docs/scrum-backlog.md Wyświetl plik

@@ -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


+ 1
- 0
src/CartWise.Application/ApplicationServiceCollectionExtensions.cs Wyświetl plik

@@ -10,6 +10,7 @@ public static class ApplicationServiceCollectionExtensions
{
services.AddSingleton(TimeProvider.System);
services.AddScoped<IHouseholdService, HouseholdService>();
services.AddScoped<IShoppingListService, ShoppingListService>();

return services;
}


+ 4
- 0
src/CartWise.Application/Interfaces/IApplicationDbContext.cs Wyświetl plik

@@ -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);
}

+ 19
- 0
src/CartWise.Application/Interfaces/IShoppingListService.cs Wyświetl plik

@@ -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);
}

+ 91
- 0
src/CartWise.Application/Services/ShoppingListService.cs Wyświetl plik

@@ -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);
}
}

+ 32
- 0
src/CartWise.Domain/Entities/GroceryConcept.cs Wyświetl plik

@@ -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;
}
}

+ 26
- 0
src/CartWise.Domain/Entities/ProductCategory.cs Wyświetl plik

@@ -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;
}
}

+ 66
- 0
src/CartWise.Domain/Entities/ShoppingList.cs Wyświetl plik

@@ -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;
}
}

+ 72
- 0
src/CartWise.Domain/Entities/ShoppingListItem.cs Wyświetl plik

@@ -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();
}
}

+ 10
- 0
src/CartWise.Domain/Enums/ShoppingListItemStatus.cs Wyświetl plik

@@ -0,0 +1,10 @@
namespace CartWise.Domain.Enums;

public enum ShoppingListItemStatus
{
Needed = 0,
Purchased = 1,
Skipped = 2,
Unavailable = 3,
Substituted = 4
}

+ 8
- 0
src/CartWise.Domain/Enums/ShoppingListStatus.cs Wyświetl plik

@@ -0,0 +1,8 @@
namespace CartWise.Domain.Enums;

public enum ShoppingListStatus
{
Active = 0,
Completed = 1,
Archived = 2
}

+ 8
- 0
src/CartWise.Infrastructure/Data/CartWiseDbContext.cs Wyświetl plik

@@ -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);


+ 29
- 0
src/CartWise.Infrastructure/Data/Configurations/GroceryConceptConfiguration.cs Wyświetl plik

@@ -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);
}
}

+ 22
- 0
src/CartWise.Infrastructure/Data/Configurations/ProductCategoryConfiguration.cs Wyświetl plik

@@ -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);
}
}

+ 33
- 0
src/CartWise.Infrastructure/Data/Configurations/ShoppingListConfiguration.cs Wyświetl plik

@@ -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);
}
}

+ 44
- 0
src/CartWise.Infrastructure/Data/Configurations/ShoppingListItemConfiguration.cs Wyświetl plik

@@ -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 });
}
}

+ 554
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.Designer.cs Wyświetl plik

@@ -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
}
}
}

+ 182
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810165235_AddShoppingList.cs Wyświetl plik

@@ -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");
}
}
}

+ 204
- 0
src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs Wyświetl plik

@@ -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
}
}


+ 115
- 0
src/CartWise.Web/Controllers/ShoppingListController.cs Wyświetl plik

@@ -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}";
}
}

+ 17
- 0
src/CartWise.Web/ViewModels/ShoppingList/AddShoppingListItemViewModel.cs Wyświetl plik

@@ -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; }
}

+ 27
- 0
src/CartWise.Web/ViewModels/ShoppingList/ShoppingListViewModel.cs Wyświetl plik

@@ -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; }
}

+ 3
- 0
src/CartWise.Web/Views/Shared/_Layout.cshtml Wyświetl plik

@@ -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>


+ 46
- 0
src/CartWise.Web/Views/ShoppingList/Index.cshtml Wyświetl plik

@@ -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");}
}

+ 12
- 0
src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml Wyświetl plik

@@ -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>

+ 124
- 0
tests/CartWise.Application.Tests/ShoppingListServiceTests.cs Wyświetl plik

@@ -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);
}
}

+ 81
- 0
tests/CartWise.Domain.Tests/ShoppingListTests.cs Wyświetl plik

@@ -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));
}
}

+ 4
- 44
tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs Wyświetl plik

@@ -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.");
}
}

+ 123
- 0
tests/CartWise.Web.Tests/ShoppingListAccessControlTests.cs Wyświetl plik

@@ -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);
}
}

+ 45
- 0
tests/CartWise.Web.Tests/WebTestHelpers.cs Wyświetl plik

@@ -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.");
}
}

Ładowanie…
Anuluj
Zapisz

Powered by TurnKey Linux.