浏览代码

Epic 2

master
Daniel Covington 1周前
父节点
当前提交
a63ceaca0b
共有 39 个文件被更改,包括 1479 次插入46 次删除
  1. +4
    -1
      .claude/settings.json
  2. +6
    -0
      .editorconfig
  3. +12
    -0
      .idea/.idea.CartWise/.idea/dataSources.xml
  4. +13
    -2
      README.md
  5. +8
    -0
      docs/decision-log.md
  6. +49
    -22
      docs/scrum-backlog.md
  7. +16
    -0
      src/CartWise.Application/ApplicationServiceCollectionExtensions.cs
  8. +4
    -0
      src/CartWise.Application/CartWise.Application.csproj
  9. +13
    -0
      src/CartWise.Application/Interfaces/IApplicationDbContext.cs
  10. +13
    -0
      src/CartWise.Application/Interfaces/IHouseholdService.cs
  11. +21
    -0
      src/CartWise.Application/Results/Result.cs
  12. +71
    -0
      src/CartWise.Application/Services/HouseholdService.cs
  13. +56
    -0
      src/CartWise.Domain/Entities/Household.cs
  14. +31
    -0
      src/CartWise.Domain/Entities/HouseholdMember.cs
  15. +7
    -0
      src/CartWise.Domain/Enums/HouseholdRole.cs
  16. +14
    -1
      src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
  17. +29
    -0
      src/CartWise.Infrastructure/Data/Configurations/HouseholdConfiguration.cs
  18. +29
    -0
      src/CartWise.Infrastructure/Data/Configurations/HouseholdMemberConfiguration.cs
  19. +350
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.Designer.cs
  20. +81
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.cs
  21. +75
    -0
      src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
  22. +2
    -0
      src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs
  23. +35
    -0
      src/CartWise.Web/Authorization/HouseholdMemberAuthorizationHandler.cs
  24. +8
    -0
      src/CartWise.Web/Authorization/HouseholdMemberRequirement.cs
  25. +98
    -0
      src/CartWise.Web/Controllers/HouseholdController.cs
  26. +8
    -0
      src/CartWise.Web/Program.cs
  27. +11
    -0
      src/CartWise.Web/ViewModels/Household/CreateHouseholdViewModel.cs
  28. +25
    -0
      src/CartWise.Web/ViewModels/Household/HouseholdIndexViewModel.cs
  29. +24
    -0
      src/CartWise.Web/Views/Household/Create.cshtml
  30. +28
    -0
      src/CartWise.Web/Views/Household/Index.cshtml
  31. +6
    -0
      src/CartWise.Web/Views/Shared/_Layout.cshtml
  32. +2
    -0
      tests/CartWise.Application.Tests/CartWise.Application.Tests.csproj
  33. +83
    -0
      tests/CartWise.Application.Tests/HouseholdServiceTests.cs
  34. +0
    -10
      tests/CartWise.Application.Tests/UnitTest1.cs
  35. +76
    -0
      tests/CartWise.Domain.Tests/HouseholdTests.cs
  36. +0
    -10
      tests/CartWise.Domain.Tests/UnitTest1.cs
  37. +87
    -0
      tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs
  38. +66
    -0
      tests/CartWise.Web.Tests/HouseholdAuthorizationTests.cs
  39. +18
    -0
      tests/CartWise.Web.Tests/HtmlAssert.cs

+ 4
- 1
.claude/settings.json 查看文件

@@ -16,7 +16,10 @@
"Bash(dotnet tool *)",
"Bash(rm -f \"g:/development/C Sharp AI/CartWise/src/CartWise.Web/App_Data/cartwise.db\")",
"Bash(ASPNETCORE_URLS=\"http://127.0.0.1:5187\" ASPNETCORE_ENVIRONMENT=\"Development\" dotnet run --no-launch-profile)",
"Bash(xargs -I{} sh -c 'echo \"=== {} ===\"; cat {}')"
"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')"
]
}
}

+ 6
- 0
.editorconfig 查看文件

@@ -391,3 +391,9 @@ dotnet_naming_style.s_camelcase.capitalization = camel_case
# Test method names use the xUnit/NUnit Method_Scenario_Expected convention.
[tests/**.cs]
dotnet_diagnostic.CA1707.severity = none

# Result<T>.Success/Failure static factory methods are the intended API shape (common
# pattern, e.g. FluentResults/ErrorOr) despite CA1000's general "avoid static members on
# generic types" guidance.
[src/CartWise.Application/Results/**.cs]
dotnet_diagnostic.CA1000.severity = none

+ 12
- 0
.idea/.idea.CartWise/.idea/dataSources.xml 查看文件

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="verify.db" uuid="14da974c-9f66-4dc4-8d2b-32b9c3a4ed59">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:G:\development\C Sharp AI\CartWise\src\CartWise.Web\verify.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

+ 13
- 2
README.md 查看文件

@@ -95,9 +95,9 @@ tests/

## Planned Features

### Phase 0 - Repository and Baseline
### Phase 0 - Repository and Baseline (Complete)
- Solution and project setup
- PostgreSQL configuration
- SQLite configuration for MVP (PostgreSQL remains the planned hosted-production target — see `docs/decision-log.md` DEC-005)
- Identity authentication
- Build and test baseline

@@ -146,11 +146,22 @@ This repository is being built from the `AGENTS.md` build specification. The cur

Progress so far (see `docs/scrum-backlog.md` for the authoritative task-level state):

`CW-EPIC-01` Platform Foundation is complete:

- [x] `CW-STORY-01.1` Solution skeleton — 4 src projects + 3 test projects, correct dependency direction, nullable enabled
- [x] `CW-STORY-01.2` Application startup — MVC shell runs, SQLite wired via `CartWiseDbContext`, `wwwroot` structure organized
- [x] `CW-STORY-01.3` ASP.NET Core Identity authentication — register, sign in, sign out
- [x] `CW-STORY-01.4` Engineering baseline — shared analyzer settings, `.editorconfig`, tuned logging, smoke tests

`CW-EPIC-02` Household and Access Control is complete:

- [x] `CW-STORY-02.1` Household/HouseholdMember entities, EF configuration, migration, domain tests
- [x] `CW-STORY-02.2` `HouseholdService` — create household, resolve current household, membership checks
- [x] `CW-STORY-02.3` Household UI — create and overview pages
- [x] `CW-STORY-02.4` Reusable `"HouseholdMember"` authorization policy, cross-household isolation verified

Next up: `CW-EPIC-03` Smart Shopping List.

## Project Planning

- Delivery guidance and architectural rules live in `AGENTS.md`


+ 8
- 0
docs/decision-log.md 查看文件

@@ -89,3 +89,11 @@ This file records product, scope, architecture, and delivery decisions for CartW
- Reason: Price history and replenishment value can be delivered without sophisticated scan or shopping workflows.
- Impact: Purchase and price stories stay in MVP while more advanced purchase linkage can evolve later.
- Revisit Trigger: Manual purchase entry creates too much friction during testing.

### DEC-009 - One household per user for MVP
- Date: 2026-08-10
- Status: Accepted
- Decision: A user belongs to exactly one household. `CW-STORY-02.2`'s `HouseholdService.CreateHouseholdAsync` rejects creating a second household for a user who already has a membership row anywhere; `GetCurrentHouseholdAsync` resolves "current household" unambiguously from that single membership.
- Reason: AGENTS.md and the backlog use "current household" language without defining cardinality. One-per-user is the smallest working vertical slice — no household-switcher UI, no "which household" ambiguity anywhere downstream (list, purchases, running-low).
- Impact: No schema-level uniqueness constraint was added (`HouseholdMember`'s composite PK is `(HouseholdId, UserId)`, which would technically allow multiple rows for the same user), so this is enforced only at the `HouseholdService` application layer for now. Multi-household support remains possible later without a migration.
- Revisit Trigger: A real need emerges for one person to participate in more than one household (e.g. managing groceries for two homes).

+ 49
- 22
docs/scrum-backlog.md 查看文件

@@ -214,12 +214,14 @@ A story is done when:
- EF configurations and migration are created

**Tasks**
- [ ] Create `Household` entity
- [ ] Create `HouseholdMember` entity
- [ ] Add role enum
- [ ] Add EF configurations
- [ ] Create migration
- [ ] Add domain tests
- [x] Create `Household` entity
- [x] Create `HouseholdMember` entity
- [x] Add role enum
- [x] Add EF configurations
- [x] Create migration
- [x] Add domain tests

**Status:** Done — `Household` and `HouseholdMember` live in `CartWise.Domain.Entities`, `HouseholdRole` (`Owner`/`Member`) in `CartWise.Domain.Enums`. `Household` is the aggregate root: its constructor auto-creates an `Owner` membership for the creator, `AddMember` rejects duplicate users, and `HouseholdMember`'s constructor is `internal` so members can only be created through the aggregate — Domain stays free of any ASP.NET Core/Identity reference (`CreatedByUserId`/`UserId` are plain strings, not `ApplicationUser` navigations). `HouseholdConfiguration`/`HouseholdMemberConfiguration` in `CartWise.Infrastructure/Data/Configurations` establish the real FK to `AspNetUsers` (`Restrict` on `Household.CreatedByUserId` to protect household history from user-deletion cascades; `Cascade` on `HouseholdMember` rows), a composite PK on `(HouseholdId, UserId)`, and an index on `HouseholdMember.UserId` for "which households does this user belong to" lookups. `AddHousehold` migration created and verified applying cleanly against a scratch SQLite database (and implicitly on every Web smoke test run, since those auto-migrate on boot). 11 domain tests added covering the invariants above.

### `CW-STORY-02.2` Implement household service
**Release:** MVP
@@ -233,12 +235,14 @@ A story is done when:
- Membership checks are reusable

**Tasks**
- [ ] Create `IHouseholdService`
- [ ] Implement `HouseholdService`
- [ ] Add create household use case
- [ ] Add get current household use case
- [ ] Add membership verification logic
- [ ] Add application tests
- [x] Create `IHouseholdService`
- [x] Implement `HouseholdService`
- [x] Add create household use case
- [x] Add get current household use case
- [x] Add membership verification logic
- [x] Add application tests

**Status:** Done — `IHouseholdService`/`HouseholdService` live in `CartWise.Application.{Interfaces,Services}`, exposing `CreateHouseholdAsync` (returns `Result<Household>`), `GetCurrentHouseholdAsync`, and `IsMemberAsync` (the reusable membership check `CW-STORY-02.4` will build authorization on top of). One household per user is enforced at this layer — see `DEC-009`. Data access goes through a new `IApplicationDbContext` abstraction (`CartWise.Application.Interfaces`, exposing just the `DbSet<Household>`/`DbSet<HouseholdMember>` needed) rather than a generic repository, since AGENTS.md explicitly rules out "generic repository abstractions added only for pattern compliance" — `CartWiseDbContext` implements it directly and is registered against the interface in `AddInfrastructure`. Time comes from the built-in `TimeProvider` (registered as `TimeProvider.System`) rather than a bespoke clock abstraction, keeping `CreateHouseholdAsync` deterministically testable without extra packages. `AddApplication()` (new `CartWise.Application` DI extension) is called from `Program.cs` alongside `AddInfrastructure`. 6 application tests added using EF Core's InMemory provider against the real `CartWiseDbContext` (via a new `CartWise.Infrastructure` reference in `CartWise.Application.Tests`, scoped to tests only — production `CartWise.Application` still has zero reference to `CartWise.Infrastructure`).

### `CW-STORY-02.3` Build household UI
**Release:** MVP
@@ -252,12 +256,14 @@ A story is done when:
- Validation errors are shown clearly

**Tasks**
- [ ] Create `HouseholdController`
- [ ] Create household view models
- [ ] Build `Views/Household/Create.cshtml`
- [ ] Build `Views/Household/Index.cshtml`
- [ ] Add validation messaging
- [ ] Add redirect flow after creation
- [x] Create `HouseholdController`
- [x] Create household view models
- [x] Build `Views/Household/Create.cshtml`
- [x] Build `Views/Household/Index.cshtml`
- [x] Add validation messaging
- [x] Add redirect flow after creation

**Status:** Done — `HouseholdController` (`[Authorize]`, attribute-routed to `/household` and `/household/create` per AGENTS.md §11) with `HouseholdIndexViewModel`/`HouseholdMemberViewModel`/`CreateHouseholdViewModel` in `CartWise.Web.ViewModels.Household`. `Index` resolves member display names via `UserManager<ApplicationUser>` in the controller (not the Application layer, to keep `CartWise.Application` free of Identity references) and redirects to `Create` when the signed-in user has no household yet; `Create` redirects back to `Index` if a household already exists (enforcing `DEC-009`'s one-per-user rule at the UI layer too, on top of the service-layer guard). Added a "Household" nav link, visible only when authenticated. Only Create + Index were built — member invite/remove (`/household/members/*`) are explicitly out of scope per AGENTS.md, which marks invite `[future/simple v1 if desired]`, and `HouseholdService` has no add/remove-member use case yet to back them. Verified live end-to-end via `dotnet run` + curl: anonymous-to-registered-to-household-created-to-overview-shown flow, member display name and `Owner` role render correctly, revisiting `/household/create` after a household exists redirects to `/household`, and an empty-name submission redisplays the form with visible validation errors (200, not a redirect).

### `CW-STORY-02.4` Enforce household authorization
**Release:** MVP
@@ -271,10 +277,12 @@ A story is done when:
- Resource isolation is covered by tests

**Tasks**
- [ ] Add household authorization policy or helper
- [ ] Enforce membership checks in services
- [ ] Add integration tests for access control
- [ ] Add tests for unauthorized and cross-household access
- [x] Add household authorization policy or helper
- [x] Enforce membership checks in services
- [x] Add integration tests for access control
- [x] Add tests for unauthorized and cross-household access

**Status:** Done — added a reusable resource-based `"HouseholdMember"` authorization policy: `HouseholdMemberRequirement` + `HouseholdMemberAuthorizationHandler` (`CartWise.Web.Authorization`), backed by the `IHouseholdService.IsMemberAsync` check already built in `CW-STORY-02.2`, registered via `AddAuthorizationBuilder()` in `Program.cs`. Note on current scope: `HouseholdController`'s existing actions (`Index`/`Create`) never accept a client-supplied household id — they always resolve "my household" from the authenticated user's own membership — so there's no IDOR surface to protect on them today; the new policy exists so the first household-scoped resource that *does* take an id (starting with `CW-EPIC-03`'s shopping list) can apply `[Authorize(Policy = "HouseholdMember")]`/resource-based `AuthorizeAsync` immediately instead of hand-rolling the check. Verified with: a unit-style test exercising the policy through the real DI container (`HouseholdAuthorizationTests` — two real users/households, confirms the policy succeeds for the owning member and fails for a user from a different household), and Web integration tests (`HouseholdAccessControlTests`) proving an anonymous request to `/household` redirects to login, and that two independently-registered users each only ever see their own household's name on `/household` — never each other's.

---

@@ -828,6 +836,25 @@ A story is done when:
- [ ] Seed purchase and price history
- [ ] Restrict seeding to development only

### `CW-STORY-08.5` Harden HTML assertions in Web integration tests
**Release:** MVP
**Priority:** P2
**Effort:** XS
**User story:** As a developer, I want Web integration test assertions to be resilient to HTML encoding so tests don't produce false negatives on ordinary characters in test data.

**Acceptance criteria**
- Web integration test assertions against rendered HTML compare against HTML-decoded content, not raw encoded output
- Test data is not artificially restricted to avoid HTML-special characters (apostrophes, ampersands, quotes)

**Tasks**
- [x] Add an HTML-decoding assertion helper to `CartWise.Web.Tests`
- [x] Update `HouseholdAccessControlTests` to use realistic household names (including an apostrophe) via the new helper
- [x] Note the pattern so future integration tests use it by default

**Status:** Done — added `HtmlAssert.Contains`/`DoesNotContain` (`tests/CartWise.Web.Tests/HtmlAssert.cs`), which runs `WebUtility.HtmlDecode` before comparing. `HouseholdAccessControlTests` now uses `"Alice's Household"` / `"Bob's Household"` (real apostrophes) through the new helper and passes. Future Web integration tests asserting against rendered HTML should use `HtmlAssert` rather than raw `Assert.Contains`/`DoesNotContain`.

**Origin:** Discovered during `CW-STORY-02.4` verification — `EachUser_OnlySeesTheirOwnHousehold` failed against `"Alice's Household"` because Razor correctly HTML-encodes `'` as `&#x27;` in `@Model.Name`, but the test asserted a literal apostrophe. The immediate fix (switching to apostrophe-free test data) papered over the real issue: any test data with `&`, `'`, `"`, `<`, or `>` would hit the same false negative. This story is the real fix.

---

## Suggested 30-Day Delivery Sequence


+ 16
- 0
src/CartWise.Application/ApplicationServiceCollectionExtensions.cs 查看文件

@@ -0,0 +1,16 @@
using CartWise.Application.Interfaces;
using CartWise.Application.Services;
using Microsoft.Extensions.DependencyInjection;

namespace CartWise.Application;

public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddSingleton(TimeProvider.System);
services.AddScoped<IHouseholdService, HouseholdService>();

return services;
}
}

+ 4
- 0
src/CartWise.Application/CartWise.Application.csproj 查看文件

@@ -4,6 +4,10 @@
<ProjectReference Include="..\CartWise.Domain\CartWise.Domain.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>


+ 13
- 0
src/CartWise.Application/Interfaces/IApplicationDbContext.cs 查看文件

@@ -0,0 +1,13 @@
using CartWise.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace CartWise.Application.Interfaces;

public interface IApplicationDbContext
{
DbSet<Household> Households { get; }

DbSet<HouseholdMember> HouseholdMembers { get; }

Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

+ 13
- 0
src/CartWise.Application/Interfaces/IHouseholdService.cs 查看文件

@@ -0,0 +1,13 @@
using CartWise.Application.Results;
using CartWise.Domain.Entities;

namespace CartWise.Application.Interfaces;

public interface IHouseholdService
{
Task<Result<Household>> CreateHouseholdAsync(string name, string userId, CancellationToken cancellationToken = default);

Task<Household?> GetCurrentHouseholdAsync(string userId, CancellationToken cancellationToken = default);

Task<bool> IsMemberAsync(Guid householdId, string userId, CancellationToken cancellationToken = default);
}

+ 21
- 0
src/CartWise.Application/Results/Result.cs 查看文件

@@ -0,0 +1,21 @@
namespace CartWise.Application.Results;

public class Result<T>
{
public bool IsSuccess { get; }

public T? Value { get; }

public string? Error { get; }

private Result(bool isSuccess, T? value, string? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}

public static Result<T> Success(T value) => new(true, value, null);

public static Result<T> Failure(string error) => new(false, default, error);
}

+ 71
- 0
src/CartWise.Application/Services/HouseholdService.cs 查看文件

@@ -0,0 +1,71 @@
using CartWise.Application.Interfaces;
using CartWise.Application.Results;
using CartWise.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace CartWise.Application.Services;

public class HouseholdService : IHouseholdService
{
private readonly IApplicationDbContext _db;
private readonly TimeProvider _timeProvider;

public HouseholdService(IApplicationDbContext db, TimeProvider timeProvider)
{
_db = db;
_timeProvider = timeProvider;
}

public async Task<Result<Household>> CreateHouseholdAsync(string name, string userId, CancellationToken cancellationToken = default)
{
var alreadyBelongsToAHousehold = await _db.HouseholdMembers
.AsNoTracking()
.AnyAsync(m => m.UserId == userId, cancellationToken);

if (alreadyBelongsToAHousehold)
{
return Result<Household>.Failure("You already belong to a household.");
}

Household household;
try
{
household = new Household(name, userId, _timeProvider.GetUtcNow().UtcDateTime);
}
catch (ArgumentException ex)
{
return Result<Household>.Failure(ex.Message);
}

_db.Households.Add(household);
await _db.SaveChangesAsync(cancellationToken);

return Result<Household>.Success(household);
}

public async Task<Household?> GetCurrentHouseholdAsync(string userId, CancellationToken cancellationToken = default)
{
var householdId = await _db.HouseholdMembers
.AsNoTracking()
.Where(m => m.UserId == userId)
.Select(m => m.HouseholdId)
.FirstOrDefaultAsync(cancellationToken);

if (householdId == Guid.Empty)
{
return null;
}

return await _db.Households
.Include(h => h.Members)
.AsNoTracking()
.FirstOrDefaultAsync(h => h.HouseholdId == householdId, cancellationToken);
}

public async Task<bool> IsMemberAsync(Guid householdId, string userId, CancellationToken cancellationToken = default)
{
return await _db.HouseholdMembers
.AsNoTracking()
.AnyAsync(m => m.HouseholdId == householdId && m.UserId == userId, cancellationToken);
}
}

+ 56
- 0
src/CartWise.Domain/Entities/Household.cs 查看文件

@@ -0,0 +1,56 @@
using CartWise.Domain.Enums;

namespace CartWise.Domain.Entities;

public class Household
{
private readonly List<HouseholdMember> _members = [];

public Guid HouseholdId { get; private set; }

public string Name { get; private set; } = null!;

public DateTime CreatedUtc { get; private set; }

public string CreatedByUserId { get; private set; } = null!;

public IReadOnlyCollection<HouseholdMember> Members => _members.AsReadOnly();

private Household()
{
}

public Household(string name, string createdByUserId, DateTime createdUtc)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Household name is required.", nameof(name));
}

if (string.IsNullOrWhiteSpace(createdByUserId))
{
throw new ArgumentException("CreatedByUserId is required.", nameof(createdByUserId));
}

HouseholdId = Guid.NewGuid();
Name = name.Trim();
CreatedByUserId = createdByUserId;
CreatedUtc = createdUtc;

_members.Add(new HouseholdMember(HouseholdId, createdByUserId, HouseholdRole.Owner, createdUtc));
}

public HouseholdMember AddMember(string userId, HouseholdRole role, DateTime joinedUtc)
{
if (_members.Any(m => m.UserId == userId))
{
throw new InvalidOperationException("User is already a member of this household.");
}

var member = new HouseholdMember(HouseholdId, userId, role, joinedUtc);
_members.Add(member);
return member;
}

public bool IsMember(string userId) => _members.Any(m => m.UserId == userId);
}

+ 31
- 0
src/CartWise.Domain/Entities/HouseholdMember.cs 查看文件

@@ -0,0 +1,31 @@
using CartWise.Domain.Enums;

namespace CartWise.Domain.Entities;

public class HouseholdMember
{
public Guid HouseholdId { get; private set; }

public string UserId { get; private set; } = null!;

public HouseholdRole Role { get; private set; }

public DateTime JoinedUtc { get; private set; }

private HouseholdMember()
{
}

internal HouseholdMember(Guid householdId, string userId, HouseholdRole role, DateTime joinedUtc)
{
if (string.IsNullOrWhiteSpace(userId))
{
throw new ArgumentException("UserId is required.", nameof(userId));
}

HouseholdId = householdId;
UserId = userId;
Role = role;
JoinedUtc = joinedUtc;
}
}

+ 7
- 0
src/CartWise.Domain/Enums/HouseholdRole.cs 查看文件

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

public enum HouseholdRole
{
Owner = 0,
Member = 1
}

+ 14
- 1
src/CartWise.Infrastructure/Data/CartWiseDbContext.cs 查看文件

@@ -1,13 +1,26 @@
using CartWise.Application.Interfaces;
using CartWise.Domain.Entities;
using CartWise.Infrastructure.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace CartWise.Infrastructure.Data;

public class CartWiseDbContext : IdentityDbContext<ApplicationUser>
public class CartWiseDbContext : IdentityDbContext<ApplicationUser>, IApplicationDbContext
{
public CartWiseDbContext(DbContextOptions<CartWiseDbContext> options)
: base(options)
{
}

public DbSet<Household> Households => Set<Household>();

public DbSet<HouseholdMember> HouseholdMembers => Set<HouseholdMember>();

protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);

builder.ApplyConfigurationsFromAssembly(typeof(CartWiseDbContext).Assembly);
}
}

+ 29
- 0
src/CartWise.Infrastructure/Data/Configurations/HouseholdConfiguration.cs 查看文件

@@ -0,0 +1,29 @@
using CartWise.Domain.Entities;
using CartWise.Infrastructure.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace CartWise.Infrastructure.Data.Configurations;

public class HouseholdConfiguration : IEntityTypeConfiguration<Household>
{
public void Configure(EntityTypeBuilder<Household> builder)
{
builder.HasKey(h => h.HouseholdId);

builder.Property(h => h.Name)
.HasMaxLength(120)
.IsRequired();

builder.Property(h => h.CreatedByUserId)
.IsRequired();

builder.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(h => h.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);

builder.Metadata.FindNavigation(nameof(Household.Members))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}

+ 29
- 0
src/CartWise.Infrastructure/Data/Configurations/HouseholdMemberConfiguration.cs 查看文件

@@ -0,0 +1,29 @@
using CartWise.Domain.Entities;
using CartWise.Infrastructure.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace CartWise.Infrastructure.Data.Configurations;

public class HouseholdMemberConfiguration : IEntityTypeConfiguration<HouseholdMember>
{
public void Configure(EntityTypeBuilder<HouseholdMember> builder)
{
builder.HasKey(m => new { m.HouseholdId, m.UserId });

builder.Property(m => m.UserId)
.IsRequired();

builder.HasOne<Household>()
.WithMany(h => h.Members)
.HasForeignKey(m => m.HouseholdId)
.OnDelete(DeleteBehavior.Cascade);

builder.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(m => m.UserId)
.OnDelete(DeleteBehavior.Cascade);

builder.HasIndex(m => m.UserId);
}
}

+ 350
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.Designer.cs 查看文件

@@ -0,0 +1,350 @@
// <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("20260810161409_AddHousehold")]
partial class AddHousehold
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");

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.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.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("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");
});
#pragma warning restore 612, 618
}
}
}

+ 81
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.cs 查看文件

@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace CartWise.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddHousehold : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Households",
columns: table => new
{
HouseholdId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedByUserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Households", x => x.HouseholdId);
table.ForeignKey(
name: "FK_Households_AspNetUsers_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});

migrationBuilder.CreateTable(
name: "HouseholdMembers",
columns: table => new
{
HouseholdId = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<int>(type: "INTEGER", nullable: false),
JoinedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HouseholdMembers", x => new { x.HouseholdId, x.UserId });
table.ForeignKey(
name: "FK_HouseholdMembers_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HouseholdMembers_Households_HouseholdId",
column: x => x.HouseholdId,
principalTable: "Households",
principalColumn: "HouseholdId",
onDelete: ReferentialAction.Cascade);
});

migrationBuilder.CreateIndex(
name: "IX_HouseholdMembers_UserId",
table: "HouseholdMembers",
column: "UserId");

migrationBuilder.CreateIndex(
name: "IX_Households_CreatedByUserId",
table: "Households",
column: "CreatedByUserId");
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "HouseholdMembers");

migrationBuilder.DropTable(
name: "Households");
}
}
}

+ 75
- 0
src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs 查看文件

@@ -17,6 +17,52 @@ namespace CartWise.Infrastructure.Data.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");

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.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<string>("Id")
@@ -216,6 +262,30 @@ namespace CartWise.Infrastructure.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});

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("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -266,6 +336,11 @@ namespace CartWise.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});

modelBuilder.Entity("CartWise.Domain.Entities.Household", b =>
{
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}


+ 2
- 0
src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs 查看文件

@@ -1,3 +1,4 @@
using CartWise.Application.Interfaces;
using CartWise.Infrastructure.Data;
using CartWise.Infrastructure.Identity;
using Microsoft.AspNetCore.Identity;
@@ -15,6 +16,7 @@ public static class InfrastructureServiceCollectionExtensions
?? throw new InvalidOperationException("Connection string 'DefaultConnection' is not configured.");

services.AddDbContext<CartWiseDbContext>(options => options.UseSqlite(connectionString));
services.AddScoped<IApplicationDbContext>(sp => sp.GetRequiredService<CartWiseDbContext>());

services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{


+ 35
- 0
src/CartWise.Web/Authorization/HouseholdMemberAuthorizationHandler.cs 查看文件

@@ -0,0 +1,35 @@
using CartWise.Application.Interfaces;
using CartWise.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;

namespace CartWise.Web.Authorization;

public class HouseholdMemberAuthorizationHandler : AuthorizationHandler<HouseholdMemberRequirement, Guid>
{
private readonly IHouseholdService _householdService;
private readonly UserManager<ApplicationUser> _userManager;

public HouseholdMemberAuthorizationHandler(IHouseholdService householdService, UserManager<ApplicationUser> userManager)
{
_householdService = householdService;
_userManager = userManager;
}

protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
HouseholdMemberRequirement requirement,
Guid householdId)
{
var userId = _userManager.GetUserId(context.User);
if (userId is null)
{
return;
}

if (await _householdService.IsMemberAsync(householdId, userId))
{
context.Succeed(requirement);
}
}
}

+ 8
- 0
src/CartWise.Web/Authorization/HouseholdMemberRequirement.cs 查看文件

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Authorization;

namespace CartWise.Web.Authorization;

// Paired with a Guid household id as the resource in resource-based authorization calls.
public class HouseholdMemberRequirement : IAuthorizationRequirement
{
}

+ 98
- 0
src/CartWise.Web/Controllers/HouseholdController.cs 查看文件

@@ -0,0 +1,98 @@
using CartWise.Application.Interfaces;
using CartWise.Infrastructure.Identity;
using CartWise.Web.ViewModels.Household;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace CartWise.Web.Controllers;

[Authorize]
public class HouseholdController : Controller
{
private readonly IHouseholdService _householdService;
private readonly UserManager<ApplicationUser> _userManager;

public HouseholdController(IHouseholdService householdService, UserManager<ApplicationUser> userManager)
{
_householdService = householdService;
_userManager = userManager;
}

[HttpGet]
[Route("household")]
public async Task<IActionResult> Index()
{
var userId = _userManager.GetUserId(User)!;
var household = await _householdService.GetCurrentHouseholdAsync(userId);

if (household is null)
{
return RedirectToAction(nameof(Create));
}

var members = new List<HouseholdMemberViewModel>();
foreach (var member in household.Members.OrderBy(m => m.JoinedUtc))
{
var memberUser = await _userManager.FindByIdAsync(member.UserId);
var displayName = !string.IsNullOrWhiteSpace(memberUser?.DisplayName)
? memberUser.DisplayName
: memberUser?.Email ?? "Unknown";

members.Add(new HouseholdMemberViewModel
{
UserId = member.UserId,
DisplayName = displayName,
Role = member.Role,
JoinedUtc = member.JoinedUtc
});
}

var viewModel = new HouseholdIndexViewModel
{
HouseholdId = household.HouseholdId,
Name = household.Name,
CreatedUtc = household.CreatedUtc,
Members = members
};

return View(viewModel);
}

[HttpGet]
[Route("household/create")]
public async Task<IActionResult> Create()
{
var userId = _userManager.GetUserId(User)!;
var existingHousehold = await _householdService.GetCurrentHouseholdAsync(userId);

if (existingHousehold is not null)
{
return RedirectToAction(nameof(Index));
}

return View(new CreateHouseholdViewModel());
}

[HttpPost]
[Route("household/create")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateHouseholdViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}

var userId = _userManager.GetUserId(User)!;
var result = await _householdService.CreateHouseholdAsync(model.Name, userId);

if (!result.IsSuccess)
{
ModelState.AddModelError(string.Empty, result.Error!);
return View(model);
}

return RedirectToAction(nameof(Index));
}
}

+ 8
- 0
src/CartWise.Web/Program.cs 查看文件

@@ -1,13 +1,21 @@
using CartWise.Application;
using CartWise.Infrastructure;
using CartWise.Infrastructure.Data;
using CartWise.Web.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

builder.Services.AddScoped<IAuthorizationHandler, HouseholdMemberAuthorizationHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("HouseholdMember", policy => policy.Requirements.Add(new HouseholdMemberRequirement()));

var app = builder.Build();

// Configure the HTTP request pipeline.


+ 11
- 0
src/CartWise.Web/ViewModels/Household/CreateHouseholdViewModel.cs 查看文件

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;

namespace CartWise.Web.ViewModels.Household;

public class CreateHouseholdViewModel
{
[Required]
[Display(Name = "Household name")]
[StringLength(120, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
}

+ 25
- 0
src/CartWise.Web/ViewModels/Household/HouseholdIndexViewModel.cs 查看文件

@@ -0,0 +1,25 @@
using CartWise.Domain.Enums;

namespace CartWise.Web.ViewModels.Household;

public class HouseholdIndexViewModel
{
public Guid HouseholdId { get; set; }

public string Name { get; set; } = string.Empty;

public DateTime CreatedUtc { get; set; }

public List<HouseholdMemberViewModel> Members { get; set; } = [];
}

public class HouseholdMemberViewModel
{
public string UserId { get; set; } = string.Empty;

public string DisplayName { get; set; } = string.Empty;

public HouseholdRole Role { get; set; }

public DateTime JoinedUtc { get; set; }
}

+ 24
- 0
src/CartWise.Web/Views/Household/Create.cshtml 查看文件

@@ -0,0 +1,24 @@
@model CartWise.Web.ViewModels.Household.CreateHouseholdViewModel
@{
ViewData["Title"] = "Create household";
}

<h1>@ViewData["Title"]</h1>

<div class="row">
<div class="col-md-6">
<form asp-controller="Household" asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group mb-3">
<label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control" autofocus />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Create household</button>
</form>
</div>
</div>

@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

+ 28
- 0
src/CartWise.Web/Views/Household/Index.cshtml 查看文件

@@ -0,0 +1,28 @@
@model CartWise.Web.ViewModels.Household.HouseholdIndexViewModel
@{
ViewData["Title"] = Model.Name;
}

<h1>@Model.Name</h1>
<p class="text-muted">Created @Model.CreatedUtc.ToLocalTime().ToString("MMMM d, yyyy")</p>

<h2>Members</h2>
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Role</th>
<th scope="col">Joined</th>
</tr>
</thead>
<tbody>
@foreach (var member in Model.Members)
{
<tr>
<td>@member.DisplayName</td>
<td>@member.Role</td>
<td>@member.JoinedUtc.ToLocalTime().ToString("MMMM d, yyyy")</td>
</tr>
}
</tbody>
</table>

+ 6
- 0
src/CartWise.Web/Views/Shared/_Layout.cshtml 查看文件

@@ -23,6 +23,12 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
@if (User.Identity?.IsAuthenticated == true)
{
<li class="nav-item">
<a class="nav-link text-dark" asp-controller="Household" asp-action="Index">Household</a>
</li>
}
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>


+ 2
- 0
tests/CartWise.Application.Tests/CartWise.Application.Tests.csproj 查看文件

@@ -7,6 +7,7 @@

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -19,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\CartWise.Application\CartWise.Application.csproj" />
<ProjectReference Include="..\..\src\CartWise.Domain\CartWise.Domain.csproj" />
<ProjectReference Include="..\..\src\CartWise.Infrastructure\CartWise.Infrastructure.csproj" />
</ItemGroup>

</Project>

+ 83
- 0
tests/CartWise.Application.Tests/HouseholdServiceTests.cs 查看文件

@@ -0,0 +1,83 @@
using CartWise.Application.Services;
using CartWise.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;

namespace CartWise.Application.Tests;

public class HouseholdServiceTests : IDisposable
{
private readonly CartWiseDbContext _db;
private readonly HouseholdService _sut;

public HouseholdServiceTests()
{
var options = new DbContextOptionsBuilder<CartWiseDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;

_db = new CartWiseDbContext(options);
_sut = new HouseholdService(_db, TimeProvider.System);
}

public void Dispose()
{
_db.Dispose();
GC.SuppressFinalize(this);
}

[Fact]
public async Task CreateHouseholdAsync_CreatesHouseholdWithOwnerMembership()
{
var result = await _sut.CreateHouseholdAsync("The Smiths", "user-1");

Assert.True(result.IsSuccess);
Assert.Equal("The Smiths", result.Value!.Name);
Assert.True(await _sut.IsMemberAsync(result.Value.HouseholdId, "user-1"));
}

[Fact]
public async Task CreateHouseholdAsync_FailsWhenUserAlreadyBelongsToAHousehold()
{
await _sut.CreateHouseholdAsync("The Smiths", "user-1");

var result = await _sut.CreateHouseholdAsync("Second Household", "user-1");

Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}

[Fact]
public async Task CreateHouseholdAsync_FailsWhenNameIsMissing()
{
var result = await _sut.CreateHouseholdAsync(" ", "user-1");

Assert.False(result.IsSuccess);
}

[Fact]
public async Task GetCurrentHouseholdAsync_ReturnsNullWhenUserHasNoHousehold()
{
var household = await _sut.GetCurrentHouseholdAsync("user-1");

Assert.Null(household);
}

[Fact]
public async Task GetCurrentHouseholdAsync_ReturnsHouseholdForMember()
{
var created = await _sut.CreateHouseholdAsync("The Smiths", "user-1");

var household = await _sut.GetCurrentHouseholdAsync("user-1");

Assert.NotNull(household);
Assert.Equal(created.Value!.HouseholdId, household!.HouseholdId);
}

[Fact]
public async Task IsMemberAsync_ReturnsFalseForNonMember()
{
var created = await _sut.CreateHouseholdAsync("The Smiths", "user-1");

Assert.False(await _sut.IsMemberAsync(created.Value!.HouseholdId, "user-2"));
}
}

+ 0
- 10
tests/CartWise.Application.Tests/UnitTest1.cs 查看文件

@@ -1,10 +0,0 @@
namespace CartWise.Application.Tests;

public class UnitTest1
{
[Fact]
public void Test1()
{

}
}

+ 76
- 0
tests/CartWise.Domain.Tests/HouseholdTests.cs 查看文件

@@ -0,0 +1,76 @@
using CartWise.Domain.Entities;
using CartWise.Domain.Enums;

namespace CartWise.Domain.Tests;

public class HouseholdTests
{
private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);

[Fact]
public void Constructor_CreatesOwnerMembershipForCreator()
{
var household = new Household("The Smiths", "user-1", Now);

var member = Assert.Single(household.Members);
Assert.Equal("user-1", member.UserId);
Assert.Equal(HouseholdRole.Owner, member.Role);
Assert.Equal(household.HouseholdId, member.HouseholdId);
Assert.Equal(Now, member.JoinedUtc);
}

[Fact]
public void Constructor_TrimsName()
{
var household = new Household(" The Smiths ", "user-1", Now);

Assert.Equal("The Smiths", household.Name);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Constructor_ThrowsWhenNameIsMissing(string? name)
{
Assert.Throws<ArgumentException>(() => new Household(name!, "user-1", Now));
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Constructor_ThrowsWhenCreatedByUserIdIsMissing(string? userId)
{
Assert.Throws<ArgumentException>(() => new Household("The Smiths", userId!, Now));
}

[Fact]
public void AddMember_AddsMemberWithGivenRole()
{
var household = new Household("The Smiths", "user-1", Now);

var member = household.AddMember("user-2", HouseholdRole.Member, Now);

Assert.Equal(2, household.Members.Count);
Assert.Equal("user-2", member.UserId);
Assert.Equal(HouseholdRole.Member, member.Role);
}

[Fact]
public void AddMember_ThrowsWhenUserIsAlreadyAMember()
{
var household = new Household("The Smiths", "user-1", Now);

Assert.Throws<InvalidOperationException>(() => household.AddMember("user-1", HouseholdRole.Member, Now));
}

[Fact]
public void IsMember_ReturnsTrueForExistingMemberAndFalseOtherwise()
{
var household = new Household("The Smiths", "user-1", Now);

Assert.True(household.IsMember("user-1"));
Assert.False(household.IsMember("user-2"));
}
}

+ 0
- 10
tests/CartWise.Domain.Tests/UnitTest1.cs 查看文件

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

public class UnitTest1
{
[Fact]
public void Test1()
{

}
}

+ 87
- 0
tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs 查看文件

@@ -0,0 +1,87 @@
using System.Net;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Testing;

namespace CartWise.Web.Tests;

public class HouseholdAccessControlTests : IClassFixture<CartWiseWebApplicationFactory>
{
private readonly CartWiseWebApplicationFactory _factory;

public HouseholdAccessControlTests(CartWiseWebApplicationFactory factory)
{
_factory = factory;
}

[Fact]
public async Task AnonymousUser_IsRedirectedToLoginWhenRequestingHousehold()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });

var response = await client.GetAsync("/household");

Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Contains("/Account/Login", response.Headers.Location?.ToString());
}

[Fact]
public async Task EachUser_OnlySeesTheirOwnHousehold()
{
var clientA = _factory.CreateClient();
var clientB = _factory.CreateClient();

await RegisterAsync(clientA, "alice2@example.com", "Alice");
await RegisterAsync(clientB, "bob2@example.com", "Bob");

await CreateHouseholdAsync(clientA, "Alice's Household");
await CreateHouseholdAsync(clientB, "Bob's Household");

var householdPageA = await clientA.GetStringAsync("/household");
var householdPageB = await clientB.GetStringAsync("/household");

HtmlAssert.Contains("Alice's Household", householdPageA);
HtmlAssert.DoesNotContain("Bob's Household", householdPageA);

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

+ 66
- 0
tests/CartWise.Web.Tests/HouseholdAuthorizationTests.cs 查看文件

@@ -0,0 +1,66 @@
using System.Security.Claims;
using CartWise.Application.Interfaces;
using CartWise.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;

namespace CartWise.Web.Tests;

public class HouseholdAuthorizationTests : IClassFixture<CartWiseWebApplicationFactory>
{
private readonly CartWiseWebApplicationFactory _factory;

public HouseholdAuthorizationTests(CartWiseWebApplicationFactory factory)
{
_factory = factory;
}

[Fact]
public async Task HouseholdMemberPolicy_SucceedsForMemberAndFailsForNonMember()
{
using var scope = _factory.Services.CreateScope();
var services = scope.ServiceProvider;

var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
var householdService = services.GetRequiredService<IHouseholdService>();
var authorizationService = services.GetRequiredService<IAuthorizationService>();

var userA = await CreateUserAsync(userManager, "alice@example.com");
var userB = await CreateUserAsync(userManager, "bob@example.com");

var householdA = await householdService.CreateHouseholdAsync("Household A", userA.Id);
Assert.True(householdA.IsSuccess);

var principalA = CreatePrincipal(userA);
var principalB = CreatePrincipal(userB);

var ownResult = await authorizationService.AuthorizeAsync(principalA, householdA.Value!.HouseholdId, "HouseholdMember");
var crossResult = await authorizationService.AuthorizeAsync(principalB, householdA.Value!.HouseholdId, "HouseholdMember");

Assert.True(ownResult.Succeeded);
Assert.False(crossResult.Succeeded);
}

private static async Task<ApplicationUser> CreateUserAsync(UserManager<ApplicationUser> userManager, string email)
{
var user = new ApplicationUser
{
UserName = email,
Email = email,
DisplayName = email,
CreatedUtc = DateTime.UtcNow
};

var result = await userManager.CreateAsync(user, "Sup3rSecret!23");
Assert.True(result.Succeeded);

return user;
}

private static ClaimsPrincipal CreatePrincipal(ApplicationUser user)
{
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, user.Id)], "Test");
return new ClaimsPrincipal(identity);
}
}

+ 18
- 0
tests/CartWise.Web.Tests/HtmlAssert.cs 查看文件

@@ -0,0 +1,18 @@
using System.Net;

namespace CartWise.Web.Tests;

// Rendered Razor output HTML-encodes special characters (e.g. ' -> &#x27;), so raw substring
// assertions against response bodies produce false negatives for ordinary test data. Decode first.
internal static class HtmlAssert
{
public static void Contains(string expectedText, string html)
{
Assert.Contains(expectedText, WebUtility.HtmlDecode(html));
}

public static void DoesNotContain(string expectedText, string html)
{
Assert.DoesNotContain(expectedText, WebUtility.HtmlDecode(html));
}
}

正在加载...
取消
保存

Powered by TurnKey Linux.