Преглед изворни кода

Add CW-STORY-05.6: price views and product price insight

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
Daniel Covington пре 1 недеља
родитељ
комит
e9563f1057
15 измењених фајлова са 615 додато и 10 уклоњено
  1. +9
    -2
      README.md
  2. +8
    -6
      docs/scrum-backlog.md
  3. +18
    -0
      src/CartWise.Application/DTOs/PricedProductDto.cs
  4. +2
    -0
      src/CartWise.Application/Interfaces/IPriceService.cs
  5. +38
    -0
      src/CartWise.Application/Services/PriceService.cs
  6. +144
    -0
      src/CartWise.Web/Controllers/PriceController.cs
  7. +26
    -1
      src/CartWise.Web/Controllers/ProductController.cs
  8. +59
    -0
      src/CartWise.Web/ViewModels/Price/PriceViewModels.cs
  9. +6
    -0
      src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs
  10. +45
    -0
      src/CartWise.Web/Views/Price/Index.cshtml
  11. +78
    -0
      src/CartWise.Web/Views/Price/Product.cshtml
  12. +14
    -1
      src/CartWise.Web/Views/Product/Details.cshtml
  13. +3
    -0
      src/CartWise.Web/Views/Shared/_Layout.cshtml
  14. +48
    -0
      tests/CartWise.Application.Tests/PriceServiceTests.cs
  15. +117
    -0
      tests/CartWise.Web.Tests/PriceAccessControlTests.cs

+ 9
- 2
README.md Прегледај датотеку

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



+ 8
- 6
docs/scrum-backlog.md Прегледај датотеку

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

**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.

---



+ 18
- 0
src/CartWise.Application/DTOs/PricedProductDto.cs Прегледај датотеку

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

+ 2
- 0
src/CartWise.Application/Interfaces/IPriceService.cs Прегледај датотеку

@@ -20,4 +20,6 @@ public interface IPriceService
Task<PriceInsightDto?> GetHouseholdPriceInsightAsync(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);
}

+ 38
- 0
src/CartWise.Application/Services/PriceService.cs Прегледај датотеку

@@ -89,6 +89,44 @@ public class PriceService : IPriceService
.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)
{
var count = sortedPrices.Count;


+ 144
- 0
src/CartWise.Web/Controllers/PriceController.cs Прегледај датотеку

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

+ 26
- 1
src/CartWise.Web/Controllers/ProductController.cs Прегледај датотеку

@@ -1,6 +1,8 @@
using CartWise.Application.Interfaces;
using CartWise.Infrastructure.Identity;
using CartWise.Web.ViewModels.Product;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace CartWise.Web.Controllers;
@@ -9,10 +11,20 @@ namespace CartWise.Web.Controllers;
public class ProductController : Controller
{
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;
_priceService = priceService;
_householdService = householdService;
_userManager = userManager;
}

[HttpGet("products/search")]
@@ -58,6 +70,19 @@ public class ProductController : Controller
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);
}



+ 59
- 0
src/CartWise.Web/ViewModels/Price/PriceViewModels.cs Прегледај датотеку

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

+ 6
- 0
src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs Прегледај датотеку

@@ -15,4 +15,10 @@ public class ProductDetailsViewModel
public string? ImageUrl { get; set; }

public List<string> BarcodeIdentifiers { get; set; } = [];

public decimal? LatestPrice { get; set; }

public DateTime? LatestObservedUtc { get; set; }

public int PriceObservationCount { get; set; }
}

+ 45
- 0
src/CartWise.Web/Views/Price/Index.cshtml Прегледај датотеку

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

+ 78
- 0
src/CartWise.Web/Views/Price/Product.cshtml Прегледај датотеку

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

+ 14
- 1
src/CartWise.Web/Views/Product/Details.cshtml Прегледај датотеку

@@ -34,4 +34,17 @@
<button type="submit" class="btn btn-primary">Add to my list</button>
</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>

+ 3
- 0
src/CartWise.Web/Views/Shared/_Layout.cshtml Прегледај датотеку

@@ -37,6 +37,9 @@
<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-controller="Price" asp-action="Index">Prices</a>
</li>
}
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>


+ 48
- 0
tests/CartWise.Application.Tests/PriceServiceTests.cs Прегледај датотеку

@@ -1,4 +1,5 @@
using CartWise.Application.Services;
using CartWise.Domain.Entities;
using CartWise.Domain.Enums;
using CartWise.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -96,4 +97,51 @@ public class PriceServiceTests : IDisposable
Assert.Equal(4.00m, history[0].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);
}
}

+ 117
- 0
tests/CartWise.Web.Tests/PriceAccessControlTests.cs Прегледај датотеку

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

Loading…
Откажи
Сачувај

Powered by TurnKey Linux.