PriceController exposes a prices index (sortable by name/price/recency), per-product price history with manual observation logging, and real price insight on the product details page, replacing the placeholder text left from CW-EPIC-04. Completes CW-EPIC-05. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -176,9 +176,16 @@ Progress so far (see `docs/scrum-backlog.md` for the authoritative task-level st | |||||
| - [x] `CW-STORY-04.4` Real `OpenFoodFactsProductDataProvider` (typed `HttpClient`, no API key required) | - [x] `CW-STORY-04.4` Real `OpenFoodFactsProductDataProvider` (typed `HttpClient`, no API key required) | ||||
| - [x] `CW-STORY-04.5` Camera-based scan page (native `BarcodeDetector` API, manual-entry fallback) with add-to-list — verified live against the real Open Food Facts API | - [x] `CW-STORY-04.5` Camera-based scan page (native `BarcodeDetector` API, manual-entry fallback) with add-to-list — verified live against the real Open Food Facts API | ||||
| Next up: `CW-EPIC-05` Stores, Purchases, and Price Intelligence. | |||||
| `CW-EPIC-05` Stores, Purchases, and Price Intelligence is complete: | |||||
| Next up: `CW-EPIC-03` Smart Shopping List. | |||||
| - [x] `CW-STORY-05.1` Retailer/StoreLocation entities, EF configuration, migration (store tracking optional per `DEC-011`) | |||||
| - [x] `CW-STORY-05.2` Purchase/PurchaseItem entities, EF configuration, migration | |||||
| - [x] `CW-STORY-05.3` Append-only PriceObservation entity, EF configuration, migration (cascade-delete protected) | |||||
| - [x] `CW-STORY-05.4` Purchase recording flow — start, add items, complete | |||||
| - [x] `CW-STORY-05.5` `PriceService` derives price observations from completed purchases (latest/average/median/lowest/unit price) | |||||
| - [x] `CW-STORY-05.6` `PriceController` + price views (index, per-product history, manual price logging), real price insight on product details | |||||
| Next up: `CW-EPIC-06` Shopping Mode. | |||||
| ## Project Planning | ## Project Planning | ||||
| @@ -658,12 +658,14 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food | |||||
| - Product details can show historical price insight | - Product details can show historical price insight | ||||
| **Tasks** | **Tasks** | ||||
| - [ ] Create `PriceController` | |||||
| - [ ] Build `Views/Price/Index.cshtml` | |||||
| - [ ] Build `Views/Price/Product.cshtml` | |||||
| - [ ] Add price history to product details view | |||||
| - [ ] Add filtering and sorting UI | |||||
| - [ ] Add freshness and source display | |||||
| - [x] Create `PriceController` | |||||
| - [x] Build `Views/Price/Index.cshtml` | |||||
| - [x] Build `Views/Price/Product.cshtml` | |||||
| - [x] Add price history to product details view | |||||
| - [x] Add filtering and sorting UI | |||||
| - [x] Add freshness and source display | |||||
| **Status:** Done — `PriceController` exposes `GET /prices` (household's priced products, sortable by name/price-asc/price-desc/recent via a query-string `sort`), `GET /prices/product/{productId}` (full observation history plus latest/average/median/lowest/unit-price insight for that household), and `POST /prices/product/{productId}/observe` (manual "I saw this price" entry — `PriceObservationSourceType.UserEntered`, `confidenceScore: 1m`, no purchase required). Added `IPriceService.GetHouseholdPricedProductsAsync` + `PricedProductDto` to back the index page, grouping observations by product in-memory (consistent with the existing median/average approach) and falling back to "Unknown product" defensively if a product record is ever missing. `Views/Product/Details.cshtml`'s former placeholder text is replaced with real last-observed price + observation count, sourced via `IProductService`'s existing `GetDetailsAsync` combined with a new `IPriceService.GetHouseholdPriceInsightAsync` call in `ProductController.Details`, plus a link to the full price history page — closing the gap deliberately left open in `05.4`'s status note. All routes are household-scoped the same way `PurchaseController` is (resolve current household via `IHouseholdService`, never trust a client-supplied id). Added a "Prices" nav link. 5 new `PriceService` tests (empty/grouped/cross-household for `GetHouseholdPricedProductsAsync`) and 5 new `PriceController` web integration tests (anonymous redirect, empty state, 404 for unknown product, full observe→history flow, cross-household isolation) — 151 tests passing (up from 143). Verified live via `dotnet run` + curl: register → create household → `/prices` empty state → 404 for an unknown product id → existing `/products/search` route unaffected by `ProductController`'s new constructor dependencies. | |||||
| --- | --- | ||||
| @@ -0,0 +1,18 @@ | |||||
| namespace CartWise.Application.DTOs; | |||||
| public class PricedProductDto | |||||
| { | |||||
| public Guid ProductId { get; init; } | |||||
| public string ProductName { get; init; } = string.Empty; | |||||
| public decimal LatestPrice { get; init; } | |||||
| public DateTime LatestObservedUtc { get; init; } | |||||
| public decimal AveragePrice { get; init; } | |||||
| public decimal LowestPrice { get; init; } | |||||
| public int ObservationCount { get; init; } | |||||
| } | |||||
| @@ -20,4 +20,6 @@ public interface IPriceService | |||||
| Task<PriceInsightDto?> GetHouseholdPriceInsightAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); | Task<PriceInsightDto?> GetHouseholdPriceInsightAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); | ||||
| Task<IReadOnlyList<PriceObservationDto>> GetHouseholdPriceHistoryAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); | Task<IReadOnlyList<PriceObservationDto>> GetHouseholdPriceHistoryAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); | ||||
| Task<IReadOnlyList<PricedProductDto>> GetHouseholdPricedProductsAsync(Guid householdId, CancellationToken cancellationToken = default); | |||||
| } | } | ||||
| @@ -89,6 +89,44 @@ public class PriceService : IPriceService | |||||
| .ToListAsync(cancellationToken); | .ToListAsync(cancellationToken); | ||||
| } | } | ||||
| public async Task<IReadOnlyList<PricedProductDto>> GetHouseholdPricedProductsAsync(Guid householdId, CancellationToken cancellationToken = default) | |||||
| { | |||||
| var observations = await _db.PriceObservations | |||||
| .AsNoTracking() | |||||
| .Where(o => o.HouseholdId == householdId) | |||||
| .ToListAsync(cancellationToken); | |||||
| if (observations.Count == 0) | |||||
| { | |||||
| return []; | |||||
| } | |||||
| var productIds = observations.Select(o => o.ProductId).Distinct().ToList(); | |||||
| var productNames = await _db.Products | |||||
| .AsNoTracking() | |||||
| .Where(p => productIds.Contains(p.ProductId)) | |||||
| .ToDictionaryAsync(p => p.ProductId, p => p.Name, cancellationToken); | |||||
| return observations | |||||
| .GroupBy(o => o.ProductId) | |||||
| .Select(group => | |||||
| { | |||||
| var latest = group.OrderByDescending(o => o.ObservedUtc).First(); | |||||
| return new PricedProductDto | |||||
| { | |||||
| ProductId = group.Key, | |||||
| ProductName = productNames.GetValueOrDefault(group.Key, "Unknown product"), | |||||
| LatestPrice = latest.Price, | |||||
| LatestObservedUtc = latest.ObservedUtc, | |||||
| AveragePrice = Math.Round(group.Average(o => o.Price), 2), | |||||
| LowestPrice = group.Min(o => o.Price), | |||||
| ObservationCount = group.Count() | |||||
| }; | |||||
| }) | |||||
| .OrderBy(p => p.ProductName) | |||||
| .ToList(); | |||||
| } | |||||
| private static decimal CalculateMedian(List<decimal> sortedPrices) | private static decimal CalculateMedian(List<decimal> sortedPrices) | ||||
| { | { | ||||
| var count = sortedPrices.Count; | var count = sortedPrices.Count; | ||||
| @@ -0,0 +1,144 @@ | |||||
| using CartWise.Application.Interfaces; | |||||
| using CartWise.Domain.Enums; | |||||
| using CartWise.Infrastructure.Identity; | |||||
| using CartWise.Web.ViewModels.Price; | |||||
| using Microsoft.AspNetCore.Authorization; | |||||
| using Microsoft.AspNetCore.Identity; | |||||
| using Microsoft.AspNetCore.Mvc; | |||||
| namespace CartWise.Web.Controllers; | |||||
| [Authorize] | |||||
| [Route("prices")] | |||||
| public class PriceController : Controller | |||||
| { | |||||
| private readonly IPriceService _priceService; | |||||
| private readonly IProductService _productService; | |||||
| private readonly IHouseholdService _householdService; | |||||
| private readonly UserManager<ApplicationUser> _userManager; | |||||
| public PriceController( | |||||
| IPriceService priceService, | |||||
| IProductService productService, | |||||
| IHouseholdService householdService, | |||||
| UserManager<ApplicationUser> userManager) | |||||
| { | |||||
| _priceService = priceService; | |||||
| _productService = productService; | |||||
| _householdService = householdService; | |||||
| _userManager = userManager; | |||||
| } | |||||
| [HttpGet("")] | |||||
| public async Task<IActionResult> Index(string? sort) | |||||
| { | |||||
| var household = await CurrentHouseholdOrNullAsync(); | |||||
| if (household is null) | |||||
| { | |||||
| return RedirectToAction("Create", "Household"); | |||||
| } | |||||
| var products = await _priceService.GetHouseholdPricedProductsAsync(household.HouseholdId); | |||||
| IEnumerable<Application.DTOs.PricedProductDto> ordered = sort switch | |||||
| { | |||||
| "price-asc" => products.OrderBy(p => p.LatestPrice), | |||||
| "price-desc" => products.OrderByDescending(p => p.LatestPrice), | |||||
| "recent" => products.OrderByDescending(p => p.LatestObservedUtc), | |||||
| _ => products.OrderBy(p => p.ProductName) | |||||
| }; | |||||
| var viewModel = new PriceIndexViewModel | |||||
| { | |||||
| Sort = sort ?? "name", | |||||
| Products = ordered.Select(p => new PricedProductRowViewModel | |||||
| { | |||||
| ProductId = p.ProductId, | |||||
| ProductName = p.ProductName, | |||||
| LatestPrice = p.LatestPrice, | |||||
| LatestObservedUtc = p.LatestObservedUtc, | |||||
| AveragePrice = p.AveragePrice, | |||||
| LowestPrice = p.LowestPrice, | |||||
| ObservationCount = p.ObservationCount | |||||
| }).ToList() | |||||
| }; | |||||
| return View(viewModel); | |||||
| } | |||||
| [HttpGet("product/{productId:guid}")] | |||||
| public async Task<IActionResult> Product(Guid productId) | |||||
| { | |||||
| var household = await CurrentHouseholdOrNullAsync(); | |||||
| if (household is null) | |||||
| { | |||||
| return RedirectToAction("Create", "Household"); | |||||
| } | |||||
| var details = await _productService.GetDetailsAsync(productId); | |||||
| if (details is null) | |||||
| { | |||||
| return NotFound(); | |||||
| } | |||||
| var insight = await _priceService.GetHouseholdPriceInsightAsync(household.HouseholdId, productId); | |||||
| var history = await _priceService.GetHouseholdPriceHistoryAsync(household.HouseholdId, productId); | |||||
| var viewModel = new PriceProductViewModel | |||||
| { | |||||
| ProductId = productId, | |||||
| ProductName = details.Name, | |||||
| LatestPrice = insight?.LatestPrice, | |||||
| LatestObservedUtc = insight?.LatestObservedUtc, | |||||
| AveragePrice = insight?.AveragePrice, | |||||
| MedianPrice = insight?.MedianPrice, | |||||
| LowestPrice = insight?.LowestPrice, | |||||
| UnitPrice = insight?.UnitPrice, | |||||
| ObservationCount = insight?.ObservationCount ?? 0, | |||||
| History = history.Select(h => new PriceObservationRowViewModel | |||||
| { | |||||
| Price = h.Price, | |||||
| UnitPrice = h.UnitPrice, | |||||
| ObservedUtc = h.ObservedUtc, | |||||
| SourceType = h.SourceType | |||||
| }).ToList() | |||||
| }; | |||||
| return View(viewModel); | |||||
| } | |||||
| [HttpPost("product/{productId:guid}/observe")] | |||||
| [ValidateAntiForgeryToken] | |||||
| public async Task<IActionResult> Observe(Guid productId, decimal price, DateTime? observedOn) | |||||
| { | |||||
| var household = await CurrentHouseholdOrNullAsync(); | |||||
| if (household is null) | |||||
| { | |||||
| return RedirectToAction("Create", "Household"); | |||||
| } | |||||
| if (price <= 0) | |||||
| { | |||||
| ModelState.AddModelError(string.Empty, "Price must be greater than zero."); | |||||
| return RedirectToAction(nameof(Product), new { productId }); | |||||
| } | |||||
| var observedUtc = observedOn ?? DateTime.UtcNow; | |||||
| await _priceService.RecordObservationAsync( | |||||
| productId, | |||||
| price, | |||||
| observedUtc, | |||||
| PriceObservationSourceType.UserEntered, | |||||
| confidenceScore: 1m, | |||||
| householdId: household.HouseholdId); | |||||
| return RedirectToAction(nameof(Product), new { productId }); | |||||
| } | |||||
| private async Task<Domain.Entities.Household?> CurrentHouseholdOrNullAsync() | |||||
| { | |||||
| var userId = _userManager.GetUserId(User)!; | |||||
| return await _householdService.GetCurrentHouseholdAsync(userId); | |||||
| } | |||||
| } | |||||
| @@ -1,6 +1,8 @@ | |||||
| using CartWise.Application.Interfaces; | using CartWise.Application.Interfaces; | ||||
| using CartWise.Infrastructure.Identity; | |||||
| using CartWise.Web.ViewModels.Product; | using CartWise.Web.ViewModels.Product; | ||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Identity; | |||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||
| namespace CartWise.Web.Controllers; | namespace CartWise.Web.Controllers; | ||||
| @@ -9,10 +11,20 @@ namespace CartWise.Web.Controllers; | |||||
| public class ProductController : Controller | public class ProductController : Controller | ||||
| { | { | ||||
| private readonly IProductService _productService; | private readonly IProductService _productService; | ||||
| private readonly IPriceService _priceService; | |||||
| private readonly IHouseholdService _householdService; | |||||
| private readonly UserManager<ApplicationUser> _userManager; | |||||
| public ProductController(IProductService productService) | |||||
| public ProductController( | |||||
| IProductService productService, | |||||
| IPriceService priceService, | |||||
| IHouseholdService householdService, | |||||
| UserManager<ApplicationUser> userManager) | |||||
| { | { | ||||
| _productService = productService; | _productService = productService; | ||||
| _priceService = priceService; | |||||
| _householdService = householdService; | |||||
| _userManager = userManager; | |||||
| } | } | ||||
| [HttpGet("products/search")] | [HttpGet("products/search")] | ||||
| @@ -58,6 +70,19 @@ public class ProductController : Controller | |||||
| BarcodeIdentifiers = details.BarcodeIdentifiers.ToList() | BarcodeIdentifiers = details.BarcodeIdentifiers.ToList() | ||||
| }; | }; | ||||
| var userId = _userManager.GetUserId(User)!; | |||||
| var household = await _householdService.GetCurrentHouseholdAsync(userId); | |||||
| if (household is not null) | |||||
| { | |||||
| var insight = await _priceService.GetHouseholdPriceInsightAsync(household.HouseholdId, id); | |||||
| if (insight is not null) | |||||
| { | |||||
| viewModel.LatestPrice = insight.LatestPrice; | |||||
| viewModel.LatestObservedUtc = insight.LatestObservedUtc; | |||||
| viewModel.PriceObservationCount = insight.ObservationCount; | |||||
| } | |||||
| } | |||||
| return View(viewModel); | return View(viewModel); | ||||
| } | } | ||||
| @@ -0,0 +1,59 @@ | |||||
| namespace CartWise.Web.ViewModels.Price; | |||||
| public class PriceIndexViewModel | |||||
| { | |||||
| public string Sort { get; set; } = "name"; | |||||
| public List<PricedProductRowViewModel> Products { get; set; } = []; | |||||
| } | |||||
| public class PricedProductRowViewModel | |||||
| { | |||||
| public Guid ProductId { get; set; } | |||||
| public string ProductName { get; set; } = string.Empty; | |||||
| public decimal LatestPrice { get; set; } | |||||
| public DateTime LatestObservedUtc { get; set; } | |||||
| public decimal AveragePrice { get; set; } | |||||
| public decimal LowestPrice { get; set; } | |||||
| public int ObservationCount { get; set; } | |||||
| } | |||||
| public class PriceProductViewModel | |||||
| { | |||||
| public Guid ProductId { get; set; } | |||||
| public string ProductName { get; set; } = string.Empty; | |||||
| public decimal? LatestPrice { get; set; } | |||||
| public DateTime? LatestObservedUtc { get; set; } | |||||
| public decimal? AveragePrice { get; set; } | |||||
| public decimal? MedianPrice { get; set; } | |||||
| public decimal? LowestPrice { get; set; } | |||||
| public decimal? UnitPrice { get; set; } | |||||
| public int ObservationCount { get; set; } | |||||
| public List<PriceObservationRowViewModel> History { get; set; } = []; | |||||
| } | |||||
| public class PriceObservationRowViewModel | |||||
| { | |||||
| public decimal Price { get; set; } | |||||
| public decimal? UnitPrice { get; set; } | |||||
| public DateTime ObservedUtc { get; set; } | |||||
| public string SourceType { get; set; } = string.Empty; | |||||
| } | |||||
| @@ -15,4 +15,10 @@ public class ProductDetailsViewModel | |||||
| public string? ImageUrl { get; set; } | public string? ImageUrl { get; set; } | ||||
| public List<string> BarcodeIdentifiers { get; set; } = []; | public List<string> BarcodeIdentifiers { get; set; } = []; | ||||
| public decimal? LatestPrice { get; set; } | |||||
| public DateTime? LatestObservedUtc { get; set; } | |||||
| public int PriceObservationCount { get; set; } | |||||
| } | } | ||||
| @@ -0,0 +1,45 @@ | |||||
| @model CartWise.Web.ViewModels.Price.PriceIndexViewModel | |||||
| @{ | |||||
| ViewData["Title"] = "Prices"; | |||||
| } | |||||
| <h1>Prices</h1> | |||||
| @if (Model.Products.Count == 0) | |||||
| { | |||||
| <p class="text-muted">No prices recorded yet. Prices appear here once you record a purchase or log an observation.</p> | |||||
| } | |||||
| else | |||||
| { | |||||
| <form method="get" asp-controller="Price" asp-action="Index" class="row g-2 mb-3"> | |||||
| <div class="col-auto"> | |||||
| <label asp-for="Sort" class="visually-hidden"></label> | |||||
| <select name="sort" class="form-select" onchange="this.form.submit()"> | |||||
| <option value="name" selected="@(Model.Sort == "name")">Name (A–Z)</option> | |||||
| <option value="price-asc" selected="@(Model.Sort == "price-asc")">Price (low to high)</option> | |||||
| <option value="price-desc" selected="@(Model.Sort == "price-desc")">Price (high to low)</option> | |||||
| <option value="recent" selected="@(Model.Sort == "recent")">Recently observed</option> | |||||
| </select> | |||||
| </div> | |||||
| </form> | |||||
| <ul class="list-group"> | |||||
| @foreach (var product in Model.Products) | |||||
| { | |||||
| <li class="list-group-item"> | |||||
| <a asp-controller="Price" asp-action="Product" asp-route-productId="@product.ProductId" class="d-flex justify-content-between align-items-center text-decoration-none"> | |||||
| <span> | |||||
| @product.ProductName | |||||
| <br /> | |||||
| <small class="text-muted">Last observed @product.LatestObservedUtc.ToLocalTime().ToString("MMM d, yyyy")</small> | |||||
| </span> | |||||
| <span class="text-end"> | |||||
| <strong>@product.LatestPrice.ToString("C")</strong> | |||||
| <br /> | |||||
| <small class="text-muted">avg @product.AveragePrice.ToString("C") · low @product.LowestPrice.ToString("C")</small> | |||||
| </span> | |||||
| </a> | |||||
| </li> | |||||
| } | |||||
| </ul> | |||||
| } | |||||
| @@ -0,0 +1,78 @@ | |||||
| @model CartWise.Web.ViewModels.Price.PriceProductViewModel | |||||
| @{ | |||||
| ViewData["Title"] = Model.ProductName; | |||||
| } | |||||
| <h1>@Model.ProductName</h1> | |||||
| <p><a asp-controller="Product" asp-action="Details" asp-route-id="@Model.ProductId">View product details</a></p> | |||||
| @if (Model.ObservationCount == 0) | |||||
| { | |||||
| <p class="text-muted">No price history yet for this household.</p> | |||||
| } | |||||
| else | |||||
| { | |||||
| <dl class="row"> | |||||
| <dt class="col-sm-4">Last observed</dt> | |||||
| <dd class="col-sm-8">@Model.LatestPrice!.Value.ToString("C") on @Model.LatestObservedUtc!.Value.ToLocalTime().ToString("MMMM d, yyyy")</dd> | |||||
| <dt class="col-sm-4">Average</dt> | |||||
| <dd class="col-sm-8">@Model.AveragePrice!.Value.ToString("C")</dd> | |||||
| <dt class="col-sm-4">Median</dt> | |||||
| <dd class="col-sm-8">@Model.MedianPrice!.Value.ToString("C")</dd> | |||||
| <dt class="col-sm-4">Lowest</dt> | |||||
| <dd class="col-sm-8">@Model.LowestPrice!.Value.ToString("C")</dd> | |||||
| @if (Model.UnitPrice is not null) | |||||
| { | |||||
| <dt class="col-sm-4">Unit price</dt> | |||||
| <dd class="col-sm-8">@Model.UnitPrice.Value.ToString("C")</dd> | |||||
| } | |||||
| <dt class="col-sm-4">Observations</dt> | |||||
| <dd class="col-sm-8">@Model.ObservationCount</dd> | |||||
| </dl> | |||||
| } | |||||
| <h2 class="h5 mt-4">Log a price you saw</h2> | |||||
| <form asp-controller="Price" asp-action="Observe" asp-route-productId="@Model.ProductId" method="post" class="row g-2 mb-4"> | |||||
| <div class="col-6 col-sm-3"> | |||||
| <label for="price" class="visually-hidden">Price</label> | |||||
| <input type="text" inputmode="decimal" name="price" id="price" class="form-control" placeholder="Price" required /> | |||||
| </div> | |||||
| <div class="col-6 col-sm-3"> | |||||
| <label for="observedOn" class="visually-hidden">Date</label> | |||||
| <input type="date" name="observedOn" id="observedOn" class="form-control" /> | |||||
| </div> | |||||
| <div class="col-12 col-sm-2"> | |||||
| <button type="submit" class="btn btn-primary w-100">Log price</button> | |||||
| </div> | |||||
| </form> | |||||
| @if (Model.History.Count > 0) | |||||
| { | |||||
| <h2 class="h5">History</h2> | |||||
| <table class="table"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th scope="col">Date</th> | |||||
| <th scope="col">Price</th> | |||||
| <th scope="col">Unit price</th> | |||||
| <th scope="col">Source</th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| @foreach (var observation in Model.History) | |||||
| { | |||||
| <tr> | |||||
| <td>@observation.ObservedUtc.ToLocalTime().ToString("MMM d, yyyy")</td> | |||||
| <td>@observation.Price.ToString("C")</td> | |||||
| <td>@(observation.UnitPrice?.ToString("C") ?? "—")</td> | |||||
| <td>@observation.SourceType</td> | |||||
| </tr> | |||||
| } | |||||
| </tbody> | |||||
| </table> | |||||
| } | |||||
| @@ -34,4 +34,17 @@ | |||||
| <button type="submit" class="btn btn-primary">Add to my list</button> | <button type="submit" class="btn btn-primary">Add to my list</button> | ||||
| </form> | </form> | ||||
| <p class="text-muted mt-4"><em>Price history and recording a purchase will appear here once purchase tracking is available.</em></p> | |||||
| <h2 class="h5 mt-4">Price</h2> | |||||
| @if (Model.PriceObservationCount == 0) | |||||
| { | |||||
| <p class="text-muted">No price history yet for this household.</p> | |||||
| } | |||||
| else | |||||
| { | |||||
| <p> | |||||
| Last observed <strong>@Model.LatestPrice!.Value.ToString("C")</strong> | |||||
| on @Model.LatestObservedUtc!.Value.ToLocalTime().ToString("MMMM d, yyyy") | |||||
| (@Model.PriceObservationCount observation@(Model.PriceObservationCount == 1 ? "" : "s")) | |||||
| </p> | |||||
| } | |||||
| <p><a asp-controller="Price" asp-action="Product" asp-route-productId="@Model.ProductId">View full price history</a></p> | |||||
| @@ -37,6 +37,9 @@ | |||||
| <li class="nav-item"> | <li class="nav-item"> | ||||
| <a class="nav-link text-dark" asp-controller="Purchase" asp-action="Index">Purchases</a> | <a class="nav-link text-dark" asp-controller="Purchase" asp-action="Index">Purchases</a> | ||||
| </li> | </li> | ||||
| <li class="nav-item"> | |||||
| <a class="nav-link text-dark" asp-controller="Price" asp-action="Index">Prices</a> | |||||
| </li> | |||||
| } | } | ||||
| <li class="nav-item"> | <li class="nav-item"> | ||||
| <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> | <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> | ||||
| @@ -1,4 +1,5 @@ | |||||
| using CartWise.Application.Services; | using CartWise.Application.Services; | ||||
| using CartWise.Domain.Entities; | |||||
| using CartWise.Domain.Enums; | using CartWise.Domain.Enums; | ||||
| using CartWise.Infrastructure.Data; | using CartWise.Infrastructure.Data; | ||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||
| @@ -96,4 +97,51 @@ public class PriceServiceTests : IDisposable | |||||
| Assert.Equal(4.00m, history[0].Price); | Assert.Equal(4.00m, history[0].Price); | ||||
| Assert.Equal(3.00m, history[1].Price); | Assert.Equal(3.00m, history[1].Price); | ||||
| } | } | ||||
| [Fact] | |||||
| public async Task GetHouseholdPricedProductsAsync_ReturnsEmptyWhenNoObservationsExist() | |||||
| { | |||||
| var products = await _sut.GetHouseholdPricedProductsAsync(Guid.NewGuid()); | |||||
| Assert.Empty(products); | |||||
| } | |||||
| [Fact] | |||||
| public async Task GetHouseholdPricedProductsAsync_GroupsByProductAndSortsByName() | |||||
| { | |||||
| var householdId = Guid.NewGuid(); | |||||
| var milk = new Product("Milk", ProductSourceType.Manual, Now); | |||||
| var bread = new Product("Bread", ProductSourceType.Manual, Now); | |||||
| _db.Products.Add(milk); | |||||
| _db.Products.Add(bread); | |||||
| await _db.SaveChangesAsync(); | |||||
| await _sut.RecordObservationAsync(milk.ProductId, 3.00m, Now.AddDays(-1), PriceObservationSourceType.UserEntered, 1m, householdId: householdId); | |||||
| await _sut.RecordObservationAsync(milk.ProductId, 4.00m, Now, PriceObservationSourceType.UserEntered, 1m, householdId: householdId); | |||||
| await _sut.RecordObservationAsync(bread.ProductId, 2.50m, Now, PriceObservationSourceType.UserEntered, 1m, householdId: householdId); | |||||
| var products = await _sut.GetHouseholdPricedProductsAsync(householdId); | |||||
| Assert.Equal(2, products.Count); | |||||
| Assert.Equal("Bread", products[0].ProductName); | |||||
| Assert.Equal("Milk", products[1].ProductName); | |||||
| Assert.Equal(2, products[1].ObservationCount); | |||||
| Assert.Equal(4.00m, products[1].LatestPrice); | |||||
| Assert.Equal(3.50m, products[1].AveragePrice); | |||||
| Assert.Equal(3.00m, products[1].LowestPrice); | |||||
| } | |||||
| [Fact] | |||||
| public async Task GetHouseholdPricedProductsAsync_DoesNotMixDifferentHouseholds() | |||||
| { | |||||
| var milk = new Product("Milk", ProductSourceType.Manual, Now); | |||||
| _db.Products.Add(milk); | |||||
| await _db.SaveChangesAsync(); | |||||
| await _sut.RecordObservationAsync(milk.ProductId, 3.00m, Now, PriceObservationSourceType.UserEntered, 1m, householdId: Guid.NewGuid()); | |||||
| var products = await _sut.GetHouseholdPricedProductsAsync(Guid.NewGuid()); | |||||
| Assert.Empty(products); | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,117 @@ | |||||
| using System.Net; | |||||
| using CartWise.Domain.Entities; | |||||
| using CartWise.Domain.Enums; | |||||
| using CartWise.Infrastructure.Data; | |||||
| using Microsoft.AspNetCore.Mvc.Testing; | |||||
| using Microsoft.Extensions.DependencyInjection; | |||||
| namespace CartWise.Web.Tests; | |||||
| public class PriceAccessControlTests : IClassFixture<CartWiseWebApplicationFactory> | |||||
| { | |||||
| private readonly CartWiseWebApplicationFactory _factory; | |||||
| public PriceAccessControlTests(CartWiseWebApplicationFactory factory) | |||||
| { | |||||
| _factory = factory; | |||||
| } | |||||
| [Fact] | |||||
| public async Task AnonymousUser_IsRedirectedToLoginWhenRequestingPrices() | |||||
| { | |||||
| var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); | |||||
| var response = await client.GetAsync("/prices"); | |||||
| Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); | |||||
| Assert.Contains("/Account/Login", response.Headers.Location?.ToString()); | |||||
| } | |||||
| [Fact] | |||||
| public async Task AuthenticatedUser_WithNoObservations_SeesEmptyState() | |||||
| { | |||||
| var client = _factory.CreateClient(); | |||||
| await WebTestHelpers.RegisterAsync(client, "pricesempty1@example.com", "Price Watcher"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(client, "Price Empty Household"); | |||||
| var response = await client.GetAsync("/prices"); | |||||
| response.EnsureSuccessStatusCode(); | |||||
| var body = await response.Content.ReadAsStringAsync(); | |||||
| HtmlAssert.Contains("No prices recorded yet", body); | |||||
| } | |||||
| [Fact] | |||||
| public async Task ProductPage_ReturnsNotFoundForUnknownProduct() | |||||
| { | |||||
| var client = _factory.CreateClient(); | |||||
| await WebTestHelpers.RegisterAsync(client, "pricesunknown1@example.com", "Price Watcher 2"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(client, "Price Unknown Household"); | |||||
| var response = await client.GetAsync($"/prices/product/{Guid.NewGuid()}"); | |||||
| Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); | |||||
| } | |||||
| [Fact] | |||||
| public async Task ObserveThenViewProduct_ShowsLoggedPriceInHistory() | |||||
| { | |||||
| var client = _factory.CreateClient(); | |||||
| await WebTestHelpers.RegisterAsync(client, "pricesobserve1@example.com", "Price Observer"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(client, "Price Observe Household"); | |||||
| var productId = SeedProduct("Observed Oats"); | |||||
| var productHtml = await client.GetStringAsync($"/prices/product/{productId}"); | |||||
| HtmlAssert.Contains("No price history yet", productHtml); | |||||
| var token = WebTestHelpers.ExtractAntiForgeryToken(productHtml); | |||||
| var observeResponse = await client.PostAsync($"/prices/product/{productId}/observe", new FormUrlEncodedContent(new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = token, | |||||
| ["price"] = "3.49" | |||||
| })); | |||||
| observeResponse.EnsureSuccessStatusCode(); | |||||
| var afterObserveHtml = await observeResponse.Content.ReadAsStringAsync(); | |||||
| HtmlAssert.Contains("$3.49", afterObserveHtml); | |||||
| var indexHtml = await client.GetStringAsync("/prices"); | |||||
| HtmlAssert.Contains("Observed Oats", indexHtml); | |||||
| } | |||||
| [Fact] | |||||
| public async Task EachHousehold_OnlySeesItsOwnPriceHistory() | |||||
| { | |||||
| var clientA = _factory.CreateClient(); | |||||
| var clientB = _factory.CreateClient(); | |||||
| await WebTestHelpers.RegisterAsync(clientA, "pricesalice1@example.com", "Price Alice"); | |||||
| await WebTestHelpers.RegisterAsync(clientB, "pricesbob1@example.com", "Price Bob"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(clientA, "Price Household A"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(clientB, "Price Household B"); | |||||
| var productId = SeedProduct("Shared Catalog Item"); | |||||
| var productHtmlA = await clientA.GetStringAsync($"/prices/product/{productId}"); | |||||
| var tokenA = WebTestHelpers.ExtractAntiForgeryToken(productHtmlA); | |||||
| await clientA.PostAsync($"/prices/product/{productId}/observe", new FormUrlEncodedContent(new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = tokenA, | |||||
| ["price"] = "9.99" | |||||
| })); | |||||
| var indexB = await clientB.GetStringAsync("/prices"); | |||||
| Assert.DoesNotContain("Shared Catalog Item", indexB); | |||||
| } | |||||
| private Guid SeedProduct(string name) | |||||
| { | |||||
| using var scope = _factory.Services.CreateScope(); | |||||
| var db = scope.ServiceProvider.GetRequiredService<CartWiseDbContext>(); | |||||
| var product = new Product(name, ProductSourceType.Manual, DateTime.UtcNow); | |||||
| db.Products.Add(product); | |||||
| db.SaveChanges(); | |||||
| return product.ProductId; | |||||
| } | |||||
| } | |||||
Powered by TurnKey Linux.