Browse Source

CW-STORY-05.4: record household purchases

IPurchaseService (start/add-item/complete) + Purchase.AddItem/Complete
domain methods. PurchaseController implements a single-page recording
flow mirroring the proven ShoppingList UX pattern. Verified live
end-to-end via dotnet run + curl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 1 week ago
parent
commit
021f505ac4
16 changed files with 859 additions and 22 deletions
  1. +2
    -1
      .claude/settings.json
  2. +8
    -6
      docs/scrum-backlog.md
  3. +1
    -0
      src/CartWise.Application/ApplicationServiceCollectionExtensions.cs
  4. +8
    -0
      src/CartWise.Application/Interfaces/IApplicationDbContext.cs
  5. +35
    -0
      src/CartWise.Application/Interfaces/IPurchaseService.cs
  6. +135
    -0
      src/CartWise.Application/Services/PurchaseService.cs
  7. +35
    -0
      src/CartWise.Domain/Entities/Purchase.cs
  8. +1
    -1
      src/CartWise.Domain/Entities/PurchaseItem.cs
  9. +161
    -0
      src/CartWise.Web/Controllers/PurchaseController.cs
  10. +70
    -0
      src/CartWise.Web/ViewModels/Purchase/PurchaseViewModels.cs
  11. +83
    -0
      src/CartWise.Web/Views/Purchase/Details.cshtml
  12. +39
    -0
      src/CartWise.Web/Views/Purchase/Index.cshtml
  13. +3
    -0
      src/CartWise.Web/Views/Shared/_Layout.cshtml
  14. +117
    -0
      tests/CartWise.Application.Tests/PurchaseServiceTests.cs
  15. +67
    -14
      tests/CartWise.Domain.Tests/PurchaseTests.cs
  16. +94
    -0
      tests/CartWise.Web.Tests/PurchaseAccessControlTests.cs

+ 2
- 1
.claude/settings.json View File

@@ -39,7 +39,8 @@
"Bash(sed -n '/<h1>/,/Add to my list/p' /tmp/product.html)",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify4.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify4.db\" 2>&1 | tail -10 && rm -f verify4.db*)",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify5.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify5.db\" 2>&1 | tail -10 && rm -f verify5.db*)",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify6.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify6.db\" 2>&1 | tail -10 && rm -f verify6.db*)"
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify6.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify6.db\" 2>&1 | tail -10 && rm -f verify6.db*)",
"Bash(curl -s -b /tmp/cw6.txt http://127.0.0.1:5187/purchases -o /tmp/index2.html -w \"status=%{http_code}\\\\n\")"
],
"additionalDirectories": [
"G:\\development\\C Sharp AI\\CartWise"


+ 8
- 6
docs/scrum-backlog.md View File

@@ -615,12 +615,14 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food
- Shopping list items can be linked when relevant

**Tasks**
- [ ] Create `IPurchaseService`
- [ ] Implement start purchase flow
- [ ] Implement add purchase item flow
- [ ] Implement complete purchase flow
- [ ] Link purchase items to shopping list items where applicable
- [ ] Add tests
- [x] Create `IPurchaseService`
- [x] Implement start purchase flow
- [x] Implement add purchase item flow
- [x] Implement complete purchase flow
- [x] Link purchase items to shopping list items where applicable
- [x] Add tests

**Status:** Done, resolved per `DEC-008` (manual purchase entry acceptable, list linkage can be simplified). `Purchase.AddItem`/`Complete` landed here as planned (see `CW-STORY-05.2`'s note); `Purchase.IsCompleted` (`Subtotal.HasValue`) is the completion signal — there's no separate `Status` field since AGENTS.md's `Purchase` spec doesn't define one. `AddPurchaseItemAsync` accepts an optional `shoppingListItemId` to set `PurchaseItem.ShoppingListItemId` (the FK link this task asks for); it does **not** also auto-mark the source `ShoppingListItem` as purchased — that's a UX nicety beyond what "link purchase items to shopping list items" literally asks for, and the current UI doesn't yet offer a way to pick a list item when adding a purchase item anyway (no product/list-item picker built — out of scope for this story). `PurchaseController`/`Views/Purchase/*` implement a simple single-page recording flow (start → add items one at a time, mirroring the proven `ShoppingList` UX pattern → complete, after which the page becomes read-only) rather than a multi-step wizard, keeping state entirely server-side with no session/multi-request complexity. Verified live end-to-end via `dotnet run` + curl: start → add "Milk" ($7.98) → complete → subtotal/total correct → add-item form disappears once completed → index list reflects the total. 7 application tests + 3 Web tests (including cross-household isolation — a different household's purchase 404s, not 200-with-empty-data).

### `CW-STORY-05.5` Derive price intelligence
**Release:** MVP


+ 1
- 0
src/CartWise.Application/ApplicationServiceCollectionExtensions.cs View File

@@ -12,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped<IHouseholdService, HouseholdService>();
services.AddScoped<IShoppingListService, ShoppingListService>();
services.AddScoped<IProductService, ProductService>();
services.AddScoped<IPurchaseService, PurchaseService>();

return services;
}


+ 8
- 0
src/CartWise.Application/Interfaces/IApplicationDbContext.cs View File

@@ -22,6 +22,14 @@ public interface IApplicationDbContext

DbSet<ProductIdentifier> ProductIdentifiers { get; }

DbSet<StoreLocation> StoreLocations { get; }

DbSet<Purchase> Purchases { get; }

DbSet<PurchaseItem> PurchaseItems { get; }

DbSet<PriceObservation> PriceObservations { get; }

EntityEntry<TEntity> Entry<TEntity>(TEntity entity) where TEntity : class;

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


+ 35
- 0
src/CartWise.Application/Interfaces/IPurchaseService.cs View File

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

namespace CartWise.Application.Interfaces;

public interface IPurchaseService
{
Task<Result<Purchase>> StartPurchaseAsync(
Guid householdId,
string purchasedByUserId,
DateTime purchasedUtc,
Guid? storeLocationId = null,
CancellationToken cancellationToken = default);

Task<Result<PurchaseItem>> AddPurchaseItemAsync(
Guid householdId,
Guid purchaseId,
string description,
decimal linePrice,
decimal quantity = 1m,
string? unit = null,
Guid? productId = null,
Guid? shoppingListItemId = null,
CancellationToken cancellationToken = default);

Task<Result<Purchase>> CompletePurchaseAsync(
Guid householdId,
Guid purchaseId,
decimal? tax = null,
CancellationToken cancellationToken = default);

Task<Purchase?> GetPurchaseAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken = default);

Task<IReadOnlyList<Purchase>> GetHouseholdPurchasesAsync(Guid householdId, CancellationToken cancellationToken = default);
}

+ 135
- 0
src/CartWise.Application/Services/PurchaseService.cs View File

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

namespace CartWise.Application.Services;

public class PurchaseService : IPurchaseService
{
private readonly IApplicationDbContext _db;
private readonly TimeProvider _timeProvider;

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

public async Task<Result<Purchase>> StartPurchaseAsync(
Guid householdId,
string purchasedByUserId,
DateTime purchasedUtc,
Guid? storeLocationId = null,
CancellationToken cancellationToken = default)
{
Purchase purchase;
try
{
purchase = new Purchase(householdId, purchasedByUserId, purchasedUtc, _timeProvider.GetUtcNow().UtcDateTime, storeLocationId: storeLocationId);
}
catch (ArgumentException ex)
{
return Result<Purchase>.Failure(ex.Message);
}

_db.Purchases.Add(purchase);
await _db.SaveChangesAsync(cancellationToken);

return Result<Purchase>.Success(purchase);
}

public async Task<Result<PurchaseItem>> AddPurchaseItemAsync(
Guid householdId,
Guid purchaseId,
string description,
decimal linePrice,
decimal quantity = 1m,
string? unit = null,
Guid? productId = null,
Guid? shoppingListItemId = null,
CancellationToken cancellationToken = default)
{
var purchase = await FindPurchaseForHouseholdAsync(householdId, purchaseId, cancellationToken);
if (purchase is null)
{
return Result<PurchaseItem>.Failure("Purchase not found.");
}

PurchaseItem item;
try
{
item = purchase.AddItem(
description,
linePrice,
_timeProvider.GetUtcNow().UtcDateTime,
quantity: quantity,
unit: unit,
productId: productId,
shoppingListItemId: shoppingListItemId);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Result<PurchaseItem>.Failure(ex.Message);
}

// Same EF Core tracking gotcha as ShoppingListService.AddItemAsync: a new child
// discovered only via a mutated navigation collection on an already-tracked parent
// needs to be explicitly marked, since PurchaseItemId is a client-generated Guid.
_db.PurchaseItems.Add(item);
await _db.SaveChangesAsync(cancellationToken);

return Result<PurchaseItem>.Success(item);
}

public async Task<Result<Purchase>> CompletePurchaseAsync(
Guid householdId,
Guid purchaseId,
decimal? tax = null,
CancellationToken cancellationToken = default)
{
var purchase = await FindPurchaseForHouseholdAsync(householdId, purchaseId, cancellationToken);
if (purchase is null)
{
return Result<Purchase>.Failure("Purchase not found.");
}

try
{
purchase.Complete(tax);
}
catch (InvalidOperationException ex)
{
return Result<Purchase>.Failure(ex.Message);
}

await _db.SaveChangesAsync(cancellationToken);

return Result<Purchase>.Success(purchase);
}

public async Task<Purchase?> GetPurchaseAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken = default)
{
return await _db.Purchases
.Include(p => p.Items)
.AsNoTracking()
.FirstOrDefaultAsync(p => p.PurchaseId == purchaseId && p.HouseholdId == householdId, cancellationToken);
}

public async Task<IReadOnlyList<Purchase>> GetHouseholdPurchasesAsync(Guid householdId, CancellationToken cancellationToken = default)
{
return await _db.Purchases
.Include(p => p.Items)
.AsNoTracking()
.Where(p => p.HouseholdId == householdId)
.OrderByDescending(p => p.PurchasedUtc)
.ToListAsync(cancellationToken);
}

private Task<Purchase?> FindPurchaseForHouseholdAsync(Guid householdId, Guid purchaseId, CancellationToken cancellationToken)
{
return _db.Purchases
.Include(p => p.Items)
.FirstOrDefaultAsync(p => p.PurchaseId == purchaseId && p.HouseholdId == householdId, cancellationToken);
}
}

+ 35
- 0
src/CartWise.Domain/Entities/Purchase.cs View File

@@ -58,4 +58,39 @@ public class Purchase
SourceType = sourceType;
CreatedUtc = createdUtc;
}

public bool IsCompleted => Subtotal.HasValue;

public PurchaseItem AddItem(
string description,
decimal linePrice,
DateTime addedUtc,
decimal quantity = 1m,
string? unit = null,
Guid? productId = null,
Guid? groceryConceptId = null,
Guid? shoppingListItemId = null,
decimal? unitPrice = null)
{
if (IsCompleted)
{
throw new InvalidOperationException("Cannot add items to a completed purchase.");
}

var item = new PurchaseItem(PurchaseId, description, linePrice, addedUtc, quantity, unit, productId, groceryConceptId, shoppingListItemId, unitPrice);
_items.Add(item);
return item;
}

public void Complete(decimal? tax = null)
{
if (IsCompleted)
{
throw new InvalidOperationException("Purchase is already completed.");
}

Subtotal = _items.Sum(i => i.LinePrice);
Tax = tax;
Total = Subtotal + (tax ?? 0m);
}
}

+ 1
- 1
src/CartWise.Domain/Entities/PurchaseItem.cs View File

@@ -28,7 +28,7 @@ public class PurchaseItem
{
}

public PurchaseItem(
internal PurchaseItem(
Guid purchaseId,
string description,
decimal linePrice,


+ 161
- 0
src/CartWise.Web/Controllers/PurchaseController.cs View File

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

namespace CartWise.Web.Controllers;

[Authorize]
[Route("purchases")]
public class PurchaseController : Controller
{
private readonly IPurchaseService _purchaseService;
private readonly IHouseholdService _householdService;
private readonly UserManager<ApplicationUser> _userManager;

public PurchaseController(IPurchaseService purchaseService, IHouseholdService householdService, UserManager<ApplicationUser> userManager)
{
_purchaseService = purchaseService;
_householdService = householdService;
_userManager = userManager;
}

[HttpGet("")]
public async Task<IActionResult> Index()
{
var household = await CurrentHouseholdOrNullAsync();
if (household is null)
{
return RedirectToAction("Create", "Household");
}

var purchases = await _purchaseService.GetHouseholdPurchasesAsync(household.HouseholdId);

var viewModel = new PurchaseListViewModel
{
Purchases = purchases.Select(p => new PurchaseSummaryViewModel
{
PurchaseId = p.PurchaseId,
PurchasedUtc = p.PurchasedUtc,
IsCompleted = p.IsCompleted,
Total = p.Total,
ItemCount = p.Items.Count
}).ToList()
};

return View(viewModel);
}

[HttpPost("start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Start()
{
var userId = _userManager.GetUserId(User)!;
var household = await _householdService.GetCurrentHouseholdAsync(userId);
if (household is null)
{
return RedirectToAction("Create", "Household");
}

var result = await _purchaseService.StartPurchaseAsync(household.HouseholdId, userId, DateTime.UtcNow);
if (!result.IsSuccess)
{
return RedirectToAction(nameof(Index));
}

return RedirectToAction(nameof(Details), new { id = result.Value!.PurchaseId });
}

[HttpGet("{id:guid}")]
public async Task<IActionResult> Details(Guid id)
{
var household = await CurrentHouseholdOrNullAsync();
if (household is null)
{
return RedirectToAction("Create", "Household");
}

var purchase = await _purchaseService.GetPurchaseAsync(household.HouseholdId, id);
if (purchase is null)
{
return NotFound();
}

return View(ToViewModel(purchase));
}

[HttpPost("{id:guid}/items")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddItem(Guid id, [Bind(Prefix = "NewItem")] AddPurchaseItemViewModel model)
{
var household = await CurrentHouseholdOrNullAsync();
if (household is null)
{
return RedirectToAction("Create", "Household");
}

var purchase = await _purchaseService.GetPurchaseAsync(household.HouseholdId, id);
if (purchase is null)
{
return NotFound();
}

if (!ModelState.IsValid)
{
var viewModel = ToViewModel(purchase);
viewModel.NewItem = model;
return View(nameof(Details), viewModel);
}

var result = await _purchaseService.AddPurchaseItemAsync(household.HouseholdId, id, model.Description, model.LinePrice, model.Quantity, model.Unit);
if (!result.IsSuccess)
{
ModelState.AddModelError(string.Empty, result.Error!);
var viewModel = ToViewModel(purchase);
viewModel.NewItem = model;
return View(nameof(Details), viewModel);
}

return RedirectToAction(nameof(Details), new { id });
}

[HttpPost("{id:guid}/complete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Complete(Guid id)
{
var household = await CurrentHouseholdOrNullAsync();
if (household is null)
{
return RedirectToAction("Create", "Household");
}

await _purchaseService.CompletePurchaseAsync(household.HouseholdId, id);

return RedirectToAction(nameof(Details), new { id });
}

private async Task<Domain.Entities.Household?> CurrentHouseholdOrNullAsync()
{
var userId = _userManager.GetUserId(User)!;
return await _householdService.GetCurrentHouseholdAsync(userId);
}

private static PurchaseDetailsViewModel ToViewModel(Domain.Entities.Purchase purchase) => new()
{
PurchaseId = purchase.PurchaseId,
PurchasedUtc = purchase.PurchasedUtc,
IsCompleted = purchase.IsCompleted,
Subtotal = purchase.Subtotal,
Tax = purchase.Tax,
Total = purchase.Total,
Items = purchase.Items.Select(i => new PurchaseItemViewModel
{
Description = i.Description,
Quantity = i.Quantity,
Unit = i.Unit,
LinePrice = i.LinePrice
}).ToList()
};
}

+ 70
- 0
src/CartWise.Web/ViewModels/Purchase/PurchaseViewModels.cs View File

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

namespace CartWise.Web.ViewModels.Purchase;

public class PurchaseListViewModel
{
public List<PurchaseSummaryViewModel> Purchases { get; set; } = [];
}

public class PurchaseSummaryViewModel
{
public Guid PurchaseId { get; set; }

public DateTime PurchasedUtc { get; set; }

public bool IsCompleted { get; set; }

public decimal? Total { get; set; }

public int ItemCount { get; set; }
}

public class PurchaseDetailsViewModel
{
public Guid PurchaseId { get; set; }

public DateTime PurchasedUtc { get; set; }

public bool IsCompleted { get; set; }

public decimal? Subtotal { get; set; }

public decimal? Tax { get; set; }

public decimal? Total { get; set; }

public List<PurchaseItemViewModel> Items { get; set; } = [];

public AddPurchaseItemViewModel NewItem { get; set; } = new();
}

public class PurchaseItemViewModel
{
public string Description { get; set; } = string.Empty;

public decimal Quantity { get; set; }

public string? Unit { get; set; }

public decimal LinePrice { get; set; }
}

public class AddPurchaseItemViewModel
{
[Required]
[Display(Name = "Item")]
[StringLength(240, MinimumLength = 1)]
public string Description { get; set; } = string.Empty;

[Range(0.001, 100000)]
public decimal Quantity { get; set; } = 1m;

[StringLength(32)]
public string? Unit { get; set; }

[Required]
[Range(0, 100000)]
[Display(Name = "Price")]
public decimal LinePrice { get; set; }
}

+ 83
- 0
src/CartWise.Web/Views/Purchase/Details.cshtml View File

@@ -0,0 +1,83 @@
@model CartWise.Web.ViewModels.Purchase.PurchaseDetailsViewModel
@{
ViewData["Title"] = "Purchase";
}

<h1>Purchase — @Model.PurchasedUtc.ToLocalTime().ToString("MMMM d, yyyy")</h1>
<p class="text-muted">@(Model.IsCompleted ? "Completed" : "In progress")</p>

@if (Model.Items.Count == 0)
{
<p class="text-muted">No items yet.</p>
}
else
{
<table class="table">
<thead>
<tr>
<th scope="col">Item</th>
<th scope="col">Quantity</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.Description</td>
<td>@item.Quantity.ToString("0.###")@(string.IsNullOrEmpty(item.Unit) ? "" : $" {item.Unit}")</td>
<td>@item.LinePrice.ToString("C")</td>
</tr>
}
</tbody>
</table>
}

@if (!Model.IsCompleted)
{
<form asp-controller="Purchase" asp-action="AddItem" asp-route-id="@Model.PurchaseId" 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.Description" class="visually-hidden"></label>
<input asp-for="NewItem.Description" class="form-control" placeholder="Item" autofocus />
<span asp-validation-for="NewItem.Description" class="text-danger"></span>
</div>
<div class="col-4 col-sm-2">
<label asp-for="NewItem.Quantity" class="visually-hidden"></label>
<input asp-for="NewItem.Quantity" class="form-control" placeholder="Qty" inputmode="decimal" />
<span asp-validation-for="NewItem.Quantity" class="text-danger"></span>
</div>
<div class="col-4 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-4 col-sm-2">
<label asp-for="NewItem.LinePrice" class="visually-hidden"></label>
<input asp-for="NewItem.LinePrice" class="form-control" placeholder="Price" inputmode="decimal" />
<span asp-validation-for="NewItem.LinePrice" class="text-danger"></span>
</div>
<div class="col-12 col-sm-1">
<button type="submit" class="btn btn-primary w-100">Add</button>
</div>
</form>

<form asp-controller="Purchase" asp-action="Complete" asp-route-id="@Model.PurchaseId" method="post">
<button type="submit" class="btn btn-success" @(Model.Items.Count == 0 ? "disabled" : "")>Complete purchase</button>
</form>
}
else
{
<dl class="row">
<dt class="col-sm-3">Subtotal</dt>
<dd class="col-sm-9">@(Model.Subtotal?.ToString("C") ?? "—")</dd>
<dt class="col-sm-3">Tax</dt>
<dd class="col-sm-9">@(Model.Tax?.ToString("C") ?? "—")</dd>
<dt class="col-sm-3">Total</dt>
<dd class="col-sm-9">@(Model.Total?.ToString("C") ?? "—")</dd>
</dl>
}

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

+ 39
- 0
src/CartWise.Web/Views/Purchase/Index.cshtml View File

@@ -0,0 +1,39 @@
@model CartWise.Web.ViewModels.Purchase.PurchaseListViewModel
@{
ViewData["Title"] = "Purchases";
}

<h1>Purchases</h1>

<form asp-controller="Purchase" asp-action="Start" method="post" class="mb-4">
<button type="submit" class="btn btn-primary">Record a new purchase</button>
</form>

@if (Model.Purchases.Count == 0)
{
<p class="text-muted">No purchases recorded yet.</p>
}
else
{
<ul class="list-group">
@foreach (var purchase in Model.Purchases)
{
<li class="list-group-item d-flex justify-content-between align-items-center">
<a asp-controller="Purchase" asp-action="Details" asp-route-id="@purchase.PurchaseId">
@purchase.PurchasedUtc.ToLocalTime().ToString("MMMM d, yyyy")
<span class="text-muted">(@purchase.ItemCount item@(purchase.ItemCount == 1 ? "" : "s"))</span>
</a>
<span>
@if (!purchase.IsCompleted)
{
<span class="badge bg-warning text-dark">In progress</span>
}
else if (purchase.Total is not null)
{
<span>@purchase.Total.Value.ToString("C")</span>
}
</span>
</li>
}
</ul>
}

+ 3
- 0
src/CartWise.Web/Views/Shared/_Layout.cshtml View File

@@ -34,6 +34,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-controller="Scan" asp-action="Index">Scan</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-controller="Purchase" asp-action="Index">Purchases</a>
</li>
}
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>


+ 117
- 0
tests/CartWise.Application.Tests/PurchaseServiceTests.cs View File

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

namespace CartWise.Application.Tests;

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

private readonly CartWiseDbContext _db;
private readonly PurchaseService _sut;

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

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

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

[Fact]
public async Task StartPurchaseAsync_CreatesPurchase()
{
var householdId = Guid.NewGuid();

var result = await _sut.StartPurchaseAsync(householdId, "user-1", Now);

Assert.True(result.IsSuccess);
Assert.Equal(householdId, result.Value!.HouseholdId);
Assert.False(result.Value.IsCompleted);
}

[Fact]
public async Task AddPurchaseItemAsync_AddsItemAndPersistsIt()
{
var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);

var result = await _sut.AddPurchaseItemAsync(started.Value!.HouseholdId, started.Value.PurchaseId, "Milk", 3.99m);

Assert.True(result.IsSuccess);
Assert.Equal("Milk", result.Value!.Description);

var reloaded = await _sut.GetPurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId);
Assert.Single(reloaded!.Items);
}

[Fact]
public async Task AddPurchaseItemAsync_FailsForPurchaseInAnotherHousehold()
{
var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);

var result = await _sut.AddPurchaseItemAsync(Guid.NewGuid(), started.Value!.PurchaseId, "Milk", 3.99m);

Assert.False(result.IsSuccess);
}

[Fact]
public async Task AddPurchaseItemAsync_FailsWhenPurchaseAlreadyCompleted()
{
var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);
await _sut.AddPurchaseItemAsync(started.Value!.HouseholdId, started.Value.PurchaseId, "Milk", 3.99m);
await _sut.CompletePurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId);

var result = await _sut.AddPurchaseItemAsync(started.Value.HouseholdId, started.Value.PurchaseId, "Bread", 2.5m);

Assert.False(result.IsSuccess);
}

[Fact]
public async Task CompletePurchaseAsync_ComputesSubtotalAndTotal()
{
var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);
await _sut.AddPurchaseItemAsync(started.Value!.HouseholdId, started.Value.PurchaseId, "Milk", 3.99m);
await _sut.AddPurchaseItemAsync(started.Value.HouseholdId, started.Value.PurchaseId, "Bread", 2.50m);

var result = await _sut.CompletePurchaseAsync(started.Value.HouseholdId, started.Value.PurchaseId, tax: 0.50m);

Assert.True(result.IsSuccess);
Assert.Equal(6.49m, result.Value!.Subtotal);
Assert.Equal(0.50m, result.Value.Tax);
Assert.Equal(6.99m, result.Value.Total);
}

[Fact]
public async Task CompletePurchaseAsync_FailsForPurchaseInAnotherHousehold()
{
var started = await _sut.StartPurchaseAsync(Guid.NewGuid(), "user-1", Now);

var result = await _sut.CompletePurchaseAsync(Guid.NewGuid(), started.Value!.PurchaseId);

Assert.False(result.IsSuccess);
}

[Fact]
public async Task GetHouseholdPurchasesAsync_ReturnsOnlyThatHouseholdsPurchasesNewestFirst()
{
var householdA = Guid.NewGuid();
var householdB = Guid.NewGuid();
await _sut.StartPurchaseAsync(householdA, "user-1", Now.AddDays(-1));
await _sut.StartPurchaseAsync(householdA, "user-1", Now);
await _sut.StartPurchaseAsync(householdB, "user-2", Now);

var results = await _sut.GetHouseholdPurchasesAsync(householdA);

Assert.Equal(2, results.Count);
Assert.True(results[0].PurchasedUtc >= results[1].PurchasedUtc);
}
}

+ 67
- 14
tests/CartWise.Domain.Tests/PurchaseTests.cs View File

@@ -15,6 +15,7 @@ public class PurchaseTests
Assert.Equal(PurchaseSourceType.Manual, purchase.SourceType);
Assert.Null(purchase.StoreLocationId);
Assert.Empty(purchase.Items);
Assert.False(purchase.IsCompleted);
}

[Fact]
@@ -31,40 +32,92 @@ public class PurchaseTests
{
Assert.Throws<ArgumentException>(() => new Purchase(Guid.NewGuid(), userId!, Now, Now));
}
}

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

[Fact]
public void Constructor_DefaultsQuantityToOne()
public void AddItem_DefaultsQuantityToOne()
{
var item = new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now);
var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);

var item = purchase.AddItem("Milk", 3.99m, Now);

Assert.Equal(1m, item.Quantity);
Assert.Equal(3.99m, item.LinePrice);
Assert.Single(purchase.Items);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Constructor_ThrowsWhenDescriptionIsMissing(string? description)
public void AddItem_ThrowsWhenDescriptionIsMissing(string? description)
{
var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);

Assert.Throws<ArgumentException>(() => purchase.AddItem(description!, 3.99m, Now));
}

[Fact]
public void AddItem_ThrowsWhenQuantityIsZeroOrNegative()
{
var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);

Assert.Throws<ArgumentOutOfRangeException>(() => purchase.AddItem("Milk", 3.99m, Now, quantity: 0));
Assert.Throws<ArgumentOutOfRangeException>(() => purchase.AddItem("Milk", 3.99m, Now, quantity: -1));
}

[Fact]
public void AddItem_ThrowsWhenLinePriceIsNegative()
{
var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);

Assert.Throws<ArgumentOutOfRangeException>(() => purchase.AddItem("Milk", -1m, Now));
}

[Fact]
public void AddItem_ThrowsOnCompletedPurchase()
{
Assert.Throws<ArgumentException>(() => new PurchaseItem(Guid.NewGuid(), description!, 3.99m, Now));
var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
purchase.AddItem("Milk", 3.99m, Now);
purchase.Complete();

Assert.Throws<InvalidOperationException>(() => purchase.AddItem("Bread", 2.5m, Now));
}

[Fact]
public void Constructor_ThrowsWhenQuantityIsZeroOrNegative()
public void Complete_SumsLinePricesIntoSubtotalAndTotal()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now, quantity: 0));
Assert.Throws<ArgumentOutOfRangeException>(() => new PurchaseItem(Guid.NewGuid(), "Milk", 3.99m, Now, quantity: -1));
var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
purchase.AddItem("Milk", 3.99m, Now);
purchase.AddItem("Bread", 2.50m, Now);

purchase.Complete();

Assert.Equal(6.49m, purchase.Subtotal);
Assert.Equal(6.49m, purchase.Total);
Assert.Null(purchase.Tax);
Assert.True(purchase.IsCompleted);
}

[Fact]
public void Constructor_ThrowsWhenLinePriceIsNegative()
public void Complete_AddsTaxToTotalWhenProvided()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new PurchaseItem(Guid.NewGuid(), "Milk", -1m, Now));
var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
purchase.AddItem("Milk", 10m, Now);

purchase.Complete(tax: 0.80m);

Assert.Equal(10m, purchase.Subtotal);
Assert.Equal(0.80m, purchase.Tax);
Assert.Equal(10.80m, purchase.Total);
}

[Fact]
public void Complete_ThrowsWhenAlreadyCompleted()
{
var purchase = new Purchase(Guid.NewGuid(), "user-1", Now, Now);
purchase.AddItem("Milk", 3.99m, Now);
purchase.Complete();

Assert.Throws<InvalidOperationException>(() => purchase.Complete());
}
}

+ 94
- 0
tests/CartWise.Web.Tests/PurchaseAccessControlTests.cs View File

@@ -0,0 +1,94 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;

namespace CartWise.Web.Tests;

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

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

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

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

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

[Fact]
public async Task RecordPurchase_FullFlow_AddsItemAndCompletes()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "purchaseflow1@example.com", "Purchase Flow User");
await WebTestHelpers.CreateHouseholdAsync(client, "Purchase Flow Household");

var indexHtml = await client.GetStringAsync("/purchases");
var startToken = WebTestHelpers.ExtractAntiForgeryToken(indexHtml);
var startResponse = await client.PostAsync("/purchases/start", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = startToken
}));
startResponse.EnsureSuccessStatusCode();
var purchaseUrl = startResponse.RequestMessage!.RequestUri!.AbsolutePath;

var detailHtml = await client.GetStringAsync(purchaseUrl);
HtmlAssert.Contains("In progress", detailHtml);
var itemToken = WebTestHelpers.ExtractAntiForgeryToken(detailHtml);

var addItemResponse = await client.PostAsync($"{purchaseUrl}/items", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = itemToken,
["NewItem.Description"] = "Milk",
["NewItem.Quantity"] = "2",
["NewItem.Unit"] = "gal",
["NewItem.LinePrice"] = "7.98"
}));
addItemResponse.EnsureSuccessStatusCode();

var afterAddHtml = await addItemResponse.Content.ReadAsStringAsync();
HtmlAssert.Contains("Milk", afterAddHtml);

var completeToken = WebTestHelpers.ExtractAntiForgeryToken(afterAddHtml);
var completeResponse = await client.PostAsync($"{purchaseUrl}/complete", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = completeToken
}));
completeResponse.EnsureSuccessStatusCode();

var finalHtml = await completeResponse.Content.ReadAsStringAsync();
HtmlAssert.Contains("Completed", finalHtml);
HtmlAssert.Contains("$7.98", finalHtml);
}

[Fact]
public async Task EachHousehold_OnlySeesItsOwnPurchases()
{
var clientA = _factory.CreateClient();
var clientB = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(clientA, "purchasealice@example.com", "Purchase Alice");
await WebTestHelpers.RegisterAsync(clientB, "purchasebob@example.com", "Purchase Bob");
await WebTestHelpers.CreateHouseholdAsync(clientA, "Purchase Household A");
await WebTestHelpers.CreateHouseholdAsync(clientB, "Purchase Household B");

var indexA = await clientA.GetStringAsync("/purchases");
var tokenA = WebTestHelpers.ExtractAntiForgeryToken(indexA);
var startResponseA = await clientA.PostAsync("/purchases/start", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = tokenA
}));
var purchaseUrlA = startResponseA.RequestMessage!.RequestUri!.AbsolutePath;
var purchaseIdA = purchaseUrlA.Split('/').Last();

var response = await clientB.GetAsync($"/purchases/{purchaseIdA}");

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.