diff --git a/.claude/settings.json b/.claude/settings.json index e965543..39ce9d6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -20,7 +20,17 @@ "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(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*)" + "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*)", + "Bash(python3 -c ' *)", + "Bash(python -c ' *)", + "PowerShell(Get-Process -Name \"CartWise.Web\" -ErrorAction SilentlyContinue)", + "PowerShell(Stop-Process -Force -ErrorAction SilentlyContinue)", + "Bash(chromium-cli --help)", + "Bash(curl -s -b /tmp/cw4.txt http://127.0.0.1:5187/list -o /tmp/list2.html -w \"status=%{http_code}\\\\n\")", + "Bash(sed -n '/shopping-list-quick-add/,/<\\\\/form>/p' /tmp/list2.html)", + "Bash(sed -n '/shopping-list-item/,/<\\\\/li>/p' /tmp/list2.html)", + "Bash(curl -s http://127.0.0.1:5187/css/site.css)", + "PowerShell(\"done\")" ], "additionalDirectories": [ "G:\\development\\C Sharp AI\\CartWise" diff --git a/README.md b/README.md index 7fdc4c3..e20f5ba 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,16 @@ Progress so far (see `docs/scrum-backlog.md` for the authoritative task-level st - [x] `CW-STORY-02.3` Household UI — create and overview pages - [x] `CW-STORY-02.4` Reusable `"HouseholdMember"` authorization policy, cross-household isolation verified +`CW-EPIC-03` Smart Shopping List is complete: + +- [x] `CW-STORY-03.1` ShoppingList/ShoppingListItem/GroceryConcept/ProductCategory entities, EF configuration, migration +- [x] `CW-STORY-03.2` `ShoppingListService` — get/create the household's one active list +- [x] `CW-STORY-03.3` Free-text add-item flow, auto-provisioned default list, verified live end-to-end +- [x] `CW-STORY-03.4` Toggle purchased/skip/delete with optimistic concurrency handling and jQuery progressive enhancement +- [x] `CW-STORY-03.5` Mobile-first/touch-friendly polish pass (not visually verified in a browser — no browser-automation tool available in this environment; recommend a quick manual check) + +Next up: `CW-EPIC-05` Stores, Purchases, and Price Intelligence (`CW-EPIC-04` is post-MVP). + Next up: `CW-EPIC-03` Smart Shopping List. ## Project Planning diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index f71c0fe..121ff65 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -374,13 +374,21 @@ Both were caught by genuine integration tests (`ShoppingListAccessControlTests`, - Concurrency conflicts are handled safely **Tasks** -- [ ] Implement toggle purchased -- [ ] Implement skip and unskip -- [ ] Implement delete item -- [ ] Handle concurrency exceptions -- [ ] Add progressive enhancement with jQuery -- [ ] Preserve server-post fallback -- [ ] Add tests +- [x] Implement toggle purchased +- [x] Implement skip and unskip +- [x] Implement delete item +- [x] Handle concurrency exceptions +- [x] Add progressive enhancement with jQuery +- [x] Preserve server-post fallback +- [x] Add tests + +**Status:** Done — `ShoppingListItem.TogglePurchased`/`ToggleSkipped` domain methods added (each regenerates `RowVersion`); `POST /list/items/{id}/toggle` and `/skip` are true toggles (one route each, per AGENTS.md §11 — no separate "unpurchase"/"unskip" routes), `/delete` hard-deletes (no "Deleted" status exists, and list items aren't history — unlike `PriceObservation`, nothing here needs to stay append-only). `IShoppingListService.TogglePurchasedAsync`/`ToggleSkippedAsync`/`DeleteItemAsync` all take `householdId` and resolve the item via a household-scoped join query (`FindItemForHouseholdAsync`) rather than trusting the route-supplied item id alone — an item belonging to a different household is indistinguishable from "not found," so no existence is leaked. On reflection this made the `"HouseholdMember"` authorization policy from `CW-STORY-02.4` unnecessary here: query-scoping is atomic (one query both finds the item and enforces ownership) and matches the pattern already used everywhere else in the codebase, so it wasn't invoked for this story — it remains available and tested for a future scenario that needs to authorize before it can even construct the right query. + +**Concurrency handling**: the client-rendered `rowVersion` hidden field round-trips through the form; the service sets `_db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion` before mutating, so EF Core's generated `UPDATE`/`DELETE` includes the *client's* last-seen value in its `WHERE` clause (the standard EF Core disconnected-entity concurrency pattern) — a stale submission throws `DbUpdateConcurrencyException`, caught and surfaced as `Result.Failure(...)`, never a 500. + +**Progressive enhancement**: `wwwroot/js/pages/shopping-list.js` intercepts the toggle/skip/delete forms' submit via jQuery, POSTs via `$.post` (which sets `X-Requested-With: XMLHttpRequest` automatically), and the controller detects that header to return either the updated `_ListItem` partial (toggle/skip) or `204`/`409` (delete) instead of a redirect — `Views/ShoppingList/_ListItem.cshtml`'s three per-item forms work unmodified via plain server POST if JS is unavailable, satisfying "standard form posts must work where practical." + +Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` to four — see `CW-STORY-03.3`'s notes for the first two): both were test-assertion bugs on my part, not product bugs — `Assert.DoesNotContain("Purchased", html)` false-failed because the *toggle button's label* is literally "Purchased" on an unpurchased item (`isPurchased ? "Undo" : "Purchased"`), unrelated to the actual status badge. Fixed by asserting against the specific `badge bg-secondary">{status}` markup instead of a bare substring — worth remembering for `CW-STORY-03.5` and beyond: any status-driven UI where the action-button label overlaps with a possible status name needs a precise assertion, not `Contains`/`DoesNotContain` on the raw status word. Verified live end-to-end via `dotnet run` + curl: toggle Needed→Purchased shown in the status badge with the correct `rowVersion`, and an AJAX request (`X-Requested-With: XMLHttpRequest`) with a deliberately stale `rowVersion` correctly returns `409`. ### `CW-STORY-03.5` Deliver a mobile-first list UI **Release:** MVP @@ -394,11 +402,15 @@ Both were caught by genuine integration tests (`ShoppingListAccessControlTests`, - Tap targets are appropriate for shopping use **Tasks** -- [ ] Build `Views/ShoppingList/Index.cshtml` -- [ ] Build `Views/ShoppingList/_ListItem.cshtml` -- [ ] Add quick-add UI -- [ ] Add touch-friendly styles -- [ ] Add `wwwroot/js/pages/shopping-list.js` +- [x] Build `Views/ShoppingList/Index.cshtml` +- [x] Build `Views/ShoppingList/_ListItem.cshtml` +- [x] Add quick-add UI +- [x] Add touch-friendly styles +- [x] Add `wwwroot/js/pages/shopping-list.js` + +**Status:** Done, with a verification caveat — the Index/_ListItem views, quick-add UI, and `shopping-list.js` already existed functionally from `CW-STORY-03.3`/`03.4`; this story was the dedicated mobile-first/touch-friendly polish pass. Changes: (1) fixed `site.css`'s inverted base font size (was 14px on mobile, 16px on desktop — backwards, and inputs under 16px trigger iOS Safari's zoom-on-focus) to a flat 16px; (2) added `.shopping-list-quick-add`/`.shopping-list-item`/`.shopping-list-item-actions` CSS establishing ~44px-minimum touch targets on every button (removed `btn-sm`) and a 3rem-tall, 1.1rem-font quick-add input/button pair; (3) `NewItem.DisplayName` is now `col-12` at every breakpoint (not just mobile) so it's unambiguously the prominent primary action, with Quantity/Unit/Add sharing a secondary row below; (4) added `inputmode="decimal"` to the Quantity field so mobile browsers show a numeric keypad instead of a full keyboard; (5) narrow viewports (`max-width: 575.98px`) stack each list item's name above its actions instead of cramming them into one row. + +**Verification caveat:** no browser-automation tool (Playwright/`chromium-cli`) is available in this environment, so this could not be visually screenshotted at a mobile viewport as the production-readiness guidance calls for. What *was* verified: `dotnet run` + curl confirms the rendered HTML carries the new classes/attributes exactly as written, the CSS file is served and contains the expected rules, and the full test suite (67 tests, unaffected by these purely-visual changes) still passes. The founder should do a quick real-device or DevTools-mobile-emulation pass before treating this as fully done — the CSS follows standard, well-established sizing conventions (WCAG 2.5.5's ~44px target, 16px inputs) but hasn't been eyeballed. --- diff --git a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs index 706f82c..8fe358f 100644 --- a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs +++ b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs @@ -1,5 +1,6 @@ using CartWise.Domain.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace CartWise.Application.Interfaces; @@ -13,5 +14,7 @@ public interface IApplicationDbContext DbSet ShoppingListItems { get; } + EntityEntry Entry(TEntity entity) where TEntity : class; + Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/CartWise.Application/Interfaces/IShoppingListService.cs b/src/CartWise.Application/Interfaces/IShoppingListService.cs index aed95dd..67115cd 100644 --- a/src/CartWise.Application/Interfaces/IShoppingListService.cs +++ b/src/CartWise.Application/Interfaces/IShoppingListService.cs @@ -16,4 +16,10 @@ public interface IShoppingListService string? unit, string addedByUserId, CancellationToken cancellationToken = default); + + Task> TogglePurchasedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default); + + Task> ToggleSkippedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default); + + Task DeleteItemAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default); } diff --git a/src/CartWise.Application/Results/Result.cs b/src/CartWise.Application/Results/Result.cs index b3bbaba..7cbe5fd 100644 --- a/src/CartWise.Application/Results/Result.cs +++ b/src/CartWise.Application/Results/Result.cs @@ -1,5 +1,22 @@ namespace CartWise.Application.Results; +public class Result +{ + public bool IsSuccess { get; } + + public string? Error { get; } + + private Result(bool isSuccess, string? error) + { + IsSuccess = isSuccess; + Error = error; + } + + public static Result Success() => new(true, null); + + public static Result Failure(string error) => new(false, error); +} + public class Result { public bool IsSuccess { get; } diff --git a/src/CartWise.Application/Services/ShoppingListService.cs b/src/CartWise.Application/Services/ShoppingListService.cs index d934669..b747172 100644 --- a/src/CartWise.Application/Services/ShoppingListService.cs +++ b/src/CartWise.Application/Services/ShoppingListService.cs @@ -88,4 +88,83 @@ public class ShoppingListService : IShoppingListService return Result.Success(item); } + + public async Task> TogglePurchasedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default) + { + var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken); + if (item is null) + { + return Result.Failure("Item not found."); + } + + _db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion; + item.TogglePurchased(_timeProvider.GetUtcNow().UtcDateTime); + + try + { + await _db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return Result.Failure("This item was changed by someone else. Please refresh and try again."); + } + + return Result.Success(item); + } + + public async Task> ToggleSkippedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default) + { + var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken); + if (item is null) + { + return Result.Failure("Item not found."); + } + + _db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion; + item.ToggleSkipped(); + + try + { + await _db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return Result.Failure("This item was changed by someone else. Please refresh and try again."); + } + + return Result.Success(item); + } + + public async Task DeleteItemAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default) + { + var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken); + if (item is null) + { + return Result.Failure("Item not found."); + } + + _db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion; + _db.ShoppingListItems.Remove(item); + + try + { + await _db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return Result.Failure("This item was changed by someone else. Please refresh and try again."); + } + + return Result.Success(); + } + + private Task FindItemForHouseholdAsync(Guid householdId, Guid itemId, CancellationToken cancellationToken) + { + return ( + from item in _db.ShoppingListItems + join list in _db.ShoppingLists on item.ShoppingListId equals list.ShoppingListId + where item.ShoppingListItemId == itemId && list.HouseholdId == householdId + select item + ).FirstOrDefaultAsync(cancellationToken); + } } diff --git a/src/CartWise.Domain/Entities/ShoppingListItem.cs b/src/CartWise.Domain/Entities/ShoppingListItem.cs index b8a4bce..b36886b 100644 --- a/src/CartWise.Domain/Entities/ShoppingListItem.cs +++ b/src/CartWise.Domain/Entities/ShoppingListItem.cs @@ -69,4 +69,29 @@ public class ShoppingListItem AddedUtc = addedUtc; RowVersion = Guid.NewGuid(); } + + public void TogglePurchased(DateTime utcNow) + { + if (Status == ShoppingListItemStatus.Purchased) + { + Status = ShoppingListItemStatus.Needed; + PurchasedUtc = null; + } + else + { + Status = ShoppingListItemStatus.Purchased; + PurchasedUtc = utcNow; + } + + RowVersion = Guid.NewGuid(); + } + + public void ToggleSkipped() + { + Status = Status == ShoppingListItemStatus.Skipped + ? ShoppingListItemStatus.Needed + : ShoppingListItemStatus.Skipped; + + RowVersion = Guid.NewGuid(); + } } diff --git a/src/CartWise.Web/Controllers/ShoppingListController.cs b/src/CartWise.Web/Controllers/ShoppingListController.cs index 9ebad5e..671327a 100644 --- a/src/CartWise.Web/Controllers/ShoppingListController.cs +++ b/src/CartWise.Web/Controllers/ShoppingListController.cs @@ -1,5 +1,6 @@ using System.Globalization; using CartWise.Application.Interfaces; +using CartWise.Application.Results; using CartWise.Infrastructure.Identity; using CartWise.Web.ViewModels.ShoppingList; using Microsoft.AspNetCore.Authorization; @@ -72,6 +73,84 @@ public class ShoppingListController : Controller return RedirectToAction(nameof(Index)); } + [HttpPost("items/{id:guid}/toggle")] + [ValidateAntiForgeryToken] + public async Task TogglePurchased(Guid id, Guid rowVersion) + { + var household = await CurrentHouseholdOrNullAsync(); + if (household is null) + { + return RedirectToAction("Create", "Household"); + } + + var result = await _shoppingListService.TogglePurchasedAsync(household.HouseholdId, id, rowVersion); + return RespondToItemMutation(result); + } + + [HttpPost("items/{id:guid}/skip")] + [ValidateAntiForgeryToken] + public async Task ToggleSkipped(Guid id, Guid rowVersion) + { + var household = await CurrentHouseholdOrNullAsync(); + if (household is null) + { + return RedirectToAction("Create", "Household"); + } + + var result = await _shoppingListService.ToggleSkippedAsync(household.HouseholdId, id, rowVersion); + return RespondToItemMutation(result); + } + + [HttpPost("items/{id:guid}/delete")] + [ValidateAntiForgeryToken] + public async Task DeleteItem(Guid id, Guid rowVersion) + { + var household = await CurrentHouseholdOrNullAsync(); + if (household is null) + { + return RedirectToAction("Create", "Household"); + } + + var result = await _shoppingListService.DeleteItemAsync(household.HouseholdId, id, rowVersion); + + if (IsAjaxRequest()) + { + return result.IsSuccess ? NoContent() : Problem(result.Error, statusCode: StatusCodes.Status409Conflict); + } + + if (!result.IsSuccess) + { + TempData["ListError"] = result.Error; + } + + return RedirectToAction(nameof(Index)); + } + + private async Task CurrentHouseholdOrNullAsync() + { + var userId = _userManager.GetUserId(User)!; + return await _householdService.GetCurrentHouseholdAsync(userId); + } + + private IActionResult RespondToItemMutation(Result result) + { + if (IsAjaxRequest()) + { + return result.IsSuccess + ? PartialView("_ListItem", ToItemViewModel(result.Value!)) + : Problem(result.Error, statusCode: StatusCodes.Status409Conflict); + } + + if (!result.IsSuccess) + { + TempData["ListError"] = result.Error; + } + + return RedirectToAction(nameof(Index)); + } + + private bool IsAjaxRequest() => Request.Headers["X-Requested-With"] == "XMLHttpRequest"; + private async Task GetOrCreateActiveListAsync(Guid householdId, string userId) { var list = await _shoppingListService.GetActiveListAsync(householdId); @@ -91,17 +170,19 @@ public class ShoppingListController : Controller 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 - }) + .Select(ToItemViewModel) .ToList() }; + private static ShoppingListItemViewModel ToItemViewModel(Domain.Entities.ShoppingListItem item) => new() + { + Id = item.ShoppingListItemId, + DisplayName = item.DisplayName, + QuantityDisplay = FormatQuantity(item.Quantity, item.Unit), + Status = item.Status, + ConcurrencyToken = item.RowVersion + }; + private static string? FormatQuantity(decimal? quantity, string? unit) { if (quantity is null) diff --git a/src/CartWise.Web/Views/ShoppingList/Index.cshtml b/src/CartWise.Web/Views/ShoppingList/Index.cshtml index 7fb11bf..77704de 100644 --- a/src/CartWise.Web/Views/ShoppingList/Index.cshtml +++ b/src/CartWise.Web/Views/ShoppingList/Index.cshtml @@ -5,24 +5,29 @@

@Model.Name

-
+@if (TempData["ListError"] is string listError) +{ +
@listError
+} + +
-
+
- +
-
+
- +
-
+
-
+
@@ -43,4 +48,5 @@ else @section Scripts { @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} + } diff --git a/src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml b/src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml index 9d794cd..cf9bb17 100644 --- a/src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml +++ b/src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml @@ -1,6 +1,10 @@ @model CartWise.Web.ViewModels.ShoppingList.ShoppingListItemViewModel +@{ + var isPurchased = Model.Status == CartWise.Domain.Enums.ShoppingListItemStatus.Purchased; + var isSkipped = Model.Status == CartWise.Domain.Enums.ShoppingListItemStatus.Skipped; +} -
  • +
  • @Model.DisplayName @if (!string.IsNullOrEmpty(Model.QuantityDisplay)) @@ -8,5 +12,19 @@ (@Model.QuantityDisplay) } - @Model.Status + + @Model.Status +
    + + +
    +
    + + +
    +
    + + +
    +
  • diff --git a/src/CartWise.Web/wwwroot/css/site.css b/src/CartWise.Web/wwwroot/css/site.css index 819f612..e0f3c56 100644 --- a/src/CartWise.Web/wwwroot/css/site.css +++ b/src/CartWise.Web/wwwroot/css/site.css @@ -1,11 +1,7 @@ html { - font-size: 14px; -} - -@media (min-width: 768px) { - html { - font-size: 16px; - } + /* 16px base everywhere: mobile needs this at least as large as desktop for + readability, and inputs below 16px trigger iOS Safari's zoom-on-focus. */ + font-size: 16px; } .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { @@ -28,4 +24,38 @@ body { .form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { text-align: start; +} + +/* Shopping list: quick-add stays the obvious primary action, and every + tappable control meets the ~44px minimum touch target size. */ +.shopping-list-quick-add input { + min-height: 3rem; + font-size: 1.1rem; +} + +.shopping-list-quick-add button { + min-height: 3rem; + font-size: 1.1rem; +} + +.shopping-list-item { + gap: 0.5rem; +} + +.shopping-list-item-actions { + flex-wrap: wrap; + row-gap: 0.5rem; +} + +.shopping-list-item-actions .btn { + min-height: 2.75rem; + min-width: 2.75rem; + padding: 0.5rem 0.9rem; +} + +@media (max-width: 575.98px) { + .shopping-list-item { + flex-direction: column; + align-items: stretch !important; + } } \ No newline at end of file diff --git a/src/CartWise.Web/wwwroot/js/pages/.gitkeep b/src/CartWise.Web/wwwroot/js/pages/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/CartWise.Web/wwwroot/js/pages/shopping-list.js b/src/CartWise.Web/wwwroot/js/pages/shopping-list.js new file mode 100644 index 0000000..ca4ae07 --- /dev/null +++ b/src/CartWise.Web/wwwroot/js/pages/shopping-list.js @@ -0,0 +1,32 @@ +$(function () { + var $list = $('#shopping-list-items'); + + function showError(xhr) { + var message = (xhr.responseJSON && xhr.responseJSON.detail) + || 'This item could not be updated. Please refresh and try again.'; + alert(message); + } + + $list.on('submit', '.js-item-action-form', function (event) { + event.preventDefault(); + var $form = $(this); + + $.post($form.attr('action'), $form.serialize()) + .done(function (html) { + $form.closest('li').replaceWith(html); + }) + .fail(showError); + }); + + $list.on('submit', '.js-item-delete-form', function (event) { + event.preventDefault(); + var $form = $(this); + var $item = $form.closest('li'); + + $.post($form.attr('action'), $form.serialize()) + .done(function () { + $item.remove(); + }) + .fail(showError); + }); +}); diff --git a/tests/CartWise.Application.Tests/ShoppingListServiceTests.cs b/tests/CartWise.Application.Tests/ShoppingListServiceTests.cs index f7c32c4..dec92e2 100644 --- a/tests/CartWise.Application.Tests/ShoppingListServiceTests.cs +++ b/tests/CartWise.Application.Tests/ShoppingListServiceTests.cs @@ -121,4 +121,104 @@ public class ShoppingListServiceTests : IDisposable Assert.False(result.IsSuccess); } + + [Fact] + public async Task TogglePurchasedAsync_MarksItemPurchased() + { + var (householdId, item) = await CreateItemAsync(); + + var result = await _sut.TogglePurchasedAsync(householdId, item.ShoppingListItemId, item.RowVersion); + + Assert.True(result.IsSuccess); + Assert.Equal(Domain.Enums.ShoppingListItemStatus.Purchased, result.Value!.Status); + } + + [Fact] + public async Task TogglePurchasedAsync_FailsForItemInAnotherHousehold() + { + var (_, item) = await CreateItemAsync(); + + var result = await _sut.TogglePurchasedAsync(Guid.NewGuid(), item.ShoppingListItemId, item.RowVersion); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task TogglePurchasedAsync_FailsOnConcurrencyConflict() + { + var (householdId, item) = await CreateItemAsync(); + var staleRowVersion = Guid.NewGuid(); + + var result = await _sut.TogglePurchasedAsync(householdId, item.ShoppingListItemId, staleRowVersion); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + } + + [Fact] + public async Task ToggleSkippedAsync_MarksItemSkipped() + { + var (householdId, item) = await CreateItemAsync(); + + var result = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, item.RowVersion); + + Assert.True(result.IsSuccess); + Assert.Equal(Domain.Enums.ShoppingListItemStatus.Skipped, result.Value!.Status); + } + + [Fact] + public async Task ToggleSkippedAsync_UnskipsOnSecondCall() + { + var (householdId, item) = await CreateItemAsync(); + var firstResult = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, item.RowVersion); + + var secondResult = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, firstResult.Value!.RowVersion); + + Assert.True(secondResult.IsSuccess); + Assert.Equal(Domain.Enums.ShoppingListItemStatus.Needed, secondResult.Value!.Status); + } + + [Fact] + public async Task DeleteItemAsync_RemovesItemFromList() + { + var (householdId, item) = await CreateItemAsync(); + + var result = await _sut.DeleteItemAsync(householdId, item.ShoppingListItemId, item.RowVersion); + + Assert.True(result.IsSuccess); + var list = await _sut.GetActiveListAsync(householdId); + Assert.Empty(list!.Items); + } + + [Fact] + public async Task DeleteItemAsync_FailsForItemInAnotherHousehold() + { + var (_, item) = await CreateItemAsync(); + + var result = await _sut.DeleteItemAsync(Guid.NewGuid(), item.ShoppingListItemId, item.RowVersion); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task DeleteItemAsync_FailsOnConcurrencyConflict() + { + var (householdId, item) = await CreateItemAsync(); + var staleRowVersion = Guid.NewGuid(); + + var result = await _sut.DeleteItemAsync(householdId, item.ShoppingListItemId, staleRowVersion); + + Assert.False(result.IsSuccess); + var list = await _sut.GetActiveListAsync(householdId); + Assert.Single(list!.Items); + } + + private async Task<(Guid HouseholdId, Domain.Entities.ShoppingListItem Item)> CreateItemAsync() + { + var householdId = Guid.NewGuid(); + var list = await _sut.CreateListAsync(householdId, "Groceries", "user-1"); + var item = await _sut.AddItemAsync(list.Value!.ShoppingListId, "Milk", null, null, "user-1"); + + return (householdId, item.Value!); + } } diff --git a/tests/CartWise.Domain.Tests/ShoppingListItemTests.cs b/tests/CartWise.Domain.Tests/ShoppingListItemTests.cs new file mode 100644 index 0000000..570de4d --- /dev/null +++ b/tests/CartWise.Domain.Tests/ShoppingListItemTests.cs @@ -0,0 +1,61 @@ +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Tests; + +public class ShoppingListItemTests +{ + private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void TogglePurchased_MarksNeededItemAsPurchased() + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + var item = list.AddItem("Milk", null, null, "user-1", Now); + var originalRowVersion = item.RowVersion; + + item.TogglePurchased(Now); + + Assert.Equal(ShoppingListItemStatus.Purchased, item.Status); + Assert.Equal(Now, item.PurchasedUtc); + Assert.NotEqual(originalRowVersion, item.RowVersion); + } + + [Fact] + public void TogglePurchased_UndoesPurchaseWhenAlreadyPurchased() + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + var item = list.AddItem("Milk", null, null, "user-1", Now); + item.TogglePurchased(Now); + + item.TogglePurchased(Now.AddMinutes(5)); + + Assert.Equal(ShoppingListItemStatus.Needed, item.Status); + Assert.Null(item.PurchasedUtc); + } + + [Fact] + public void ToggleSkipped_MarksNeededItemAsSkipped() + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + var item = list.AddItem("Milk", null, null, "user-1", Now); + var originalRowVersion = item.RowVersion; + + item.ToggleSkipped(); + + Assert.Equal(ShoppingListItemStatus.Skipped, item.Status); + Assert.NotEqual(originalRowVersion, item.RowVersion); + } + + [Fact] + public void ToggleSkipped_UnskipsWhenAlreadySkipped() + { + var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); + var item = list.AddItem("Milk", null, null, "user-1", Now); + item.ToggleSkipped(); + + item.ToggleSkipped(); + + Assert.Equal(ShoppingListItemStatus.Needed, item.Status); + } +} diff --git a/tests/CartWise.Web.Tests/ShoppingListItemActionsTests.cs b/tests/CartWise.Web.Tests/ShoppingListItemActionsTests.cs new file mode 100644 index 0000000..3852c6c --- /dev/null +++ b/tests/CartWise.Web.Tests/ShoppingListItemActionsTests.cs @@ -0,0 +1,208 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace CartWise.Web.Tests; + +public class ShoppingListItemActionsTests : IClassFixture +{ + private readonly CartWiseWebApplicationFactory _factory; + + public ShoppingListItemActionsTests(CartWiseWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task TogglePurchased_MarksItemPurchased() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "toggle1@example.com", "Toggle User"); + await WebTestHelpers.CreateHouseholdAsync(client, "Toggle Household"); + await AddItemAsync(client, "Milk"); + + var listHtml = await client.GetStringAsync("/list"); + var (id, rowVersion) = ExtractFirstItemInfo(listHtml); + var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); + + var response = await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["rowVersion"] = rowVersion + })); + response.EnsureSuccessStatusCode(); + + var updatedHtml = await client.GetStringAsync("/list"); + HtmlAssert.Contains("Purchased", updatedHtml); + } + + [Fact] + public async Task TogglePurchased_TwiceReturnsItemToNeeded() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "toggle2@example.com", "Toggle User 2"); + await WebTestHelpers.CreateHouseholdAsync(client, "Toggle Household 2"); + await AddItemAsync(client, "Milk"); + + var listHtml = await client.GetStringAsync("/list"); + var (id, rowVersion) = ExtractFirstItemInfo(listHtml); + var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); + + await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["rowVersion"] = rowVersion + })); + + var midHtml = await client.GetStringAsync("/list"); + var (_, secondRowVersion) = ExtractFirstItemInfo(midHtml); + var midToken = WebTestHelpers.ExtractAntiForgeryToken(midHtml); + + await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = midToken, + ["rowVersion"] = secondRowVersion + })); + + var finalHtml = await client.GetStringAsync("/list"); + HtmlAssert.Contains("Needed", finalHtml); + } + + [Fact] + public async Task ToggleSkipped_MarksItemSkipped() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "skip1@example.com", "Skip User"); + await WebTestHelpers.CreateHouseholdAsync(client, "Skip Household"); + await AddItemAsync(client, "Bread"); + + var listHtml = await client.GetStringAsync("/list"); + var (id, rowVersion) = ExtractFirstItemInfo(listHtml); + var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); + + var response = await client.PostAsync($"/list/items/{id}/skip", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["rowVersion"] = rowVersion + })); + response.EnsureSuccessStatusCode(); + + var updatedHtml = await client.GetStringAsync("/list"); + HtmlAssert.Contains("Skipped", updatedHtml); + } + + [Fact] + public async Task DeleteItem_RemovesItFromTheList() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "delete1@example.com", "Delete User"); + await WebTestHelpers.CreateHouseholdAsync(client, "Delete Household"); + await AddItemAsync(client, "Eggs"); + + var listHtml = await client.GetStringAsync("/list"); + var (id, rowVersion) = ExtractFirstItemInfo(listHtml); + var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); + + var response = await client.PostAsync($"/list/items/{id}/delete", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["rowVersion"] = rowVersion + })); + response.EnsureSuccessStatusCode(); + + var updatedHtml = await client.GetStringAsync("/list"); + Assert.DoesNotContain("Eggs", updatedHtml); + HtmlAssert.Contains("No items yet", updatedHtml); + } + + [Fact] + public async Task ToggleItem_FailsForAnItemInAnotherHousehold() + { + var clientA = _factory.CreateClient(); + var clientB = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(clientA, "isoalice@example.com", "Iso Alice"); + await WebTestHelpers.RegisterAsync(clientB, "isobob@example.com", "Iso Bob"); + await WebTestHelpers.CreateHouseholdAsync(clientA, "Iso Household A"); + await WebTestHelpers.CreateHouseholdAsync(clientB, "Iso Household B"); + await AddItemAsync(clientA, "Alice Only Item"); + + var listHtmlA = await clientA.GetStringAsync("/list"); + var (id, rowVersion) = ExtractFirstItemInfo(listHtmlA); + + var listHtmlB = await clientB.GetStringAsync("/list"); + var tokenB = WebTestHelpers.ExtractAntiForgeryToken(listHtmlB); + + var response = await clientB.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = tokenB, + ["rowVersion"] = rowVersion + })); + response.EnsureSuccessStatusCode(); + + var finalHtmlA = await clientA.GetStringAsync("/list"); + Assert.True(HasStatusBadge(finalHtmlA, "Needed"), "Expected the item to still show a Needed status badge."); + Assert.False(HasStatusBadge(finalHtmlA, "Purchased"), "The other household's toggle should not have changed this item's status."); + } + + [Fact] + public async Task ToggleItem_AjaxRequestWithStaleRowVersionReturnsConflict() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "conflict1@example.com", "Conflict User"); + await WebTestHelpers.CreateHouseholdAsync(client, "Conflict Household"); + await AddItemAsync(client, "Butter"); + + var listHtml = await client.GetStringAsync("/list"); + var (id, _) = ExtractFirstItemInfo(listHtml); + var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/list/items/{id}/toggle") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["rowVersion"] = Guid.NewGuid().ToString() + }) + }; + request.Headers.Add("X-Requested-With", "XMLHttpRequest"); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + private static async Task AddItemAsync(HttpClient client, string displayName) + { + var html = await client.GetStringAsync("/list"); + var token = WebTestHelpers.ExtractAntiForgeryToken(html); + + var response = await client.PostAsync("/list/items", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = token, + ["NewItem.DisplayName"] = displayName + })); + response.EnsureSuccessStatusCode(); + } + + private static bool HasStatusBadge(string html, string status) + { + return Regex.IsMatch(html, $"badge bg-secondary\">{Regex.Escape(status)}"); + } + + private static (string Id, string RowVersion) ExtractFirstItemInfo(string html) + { + var idMatch = Regex.Match(html, "id=\"list-item-([0-9a-fA-F-]{36})\""); + if (!idMatch.Success) + { + throw new InvalidOperationException("No list item found in HTML."); + } + + var window = html.Substring(idMatch.Index, Math.Min(1200, html.Length - idMatch.Index)); + var rowVersionMatch = Regex.Match(window, "name=\"rowVersion\" value=\"([^\"]+)\""); + if (!rowVersionMatch.Success) + { + throw new InvalidOperationException("No rowVersion field found for the list item."); + } + + return (idMatch.Groups[1].Value, rowVersionMatch.Groups[1].Value); + } +}