From 66c1ac1be889b4a6955bcea65a414f47e88e2375 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 10 Aug 2026 14:49:58 -0400 Subject: [PATCH] CW-STORY-04.2: search and view products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IProductService/ProductService return display-ready DTOs (BrandName/ ConceptName resolved via join) rather than raw Product entities. ProductController routes match AGENTS.md §11; price/insight fields deferred until CW-EPIC-05 exists. Co-Authored-By: Claude Sonnet 5 --- docs/scrum-backlog.md | 16 ++-- .../ApplicationServiceCollectionExtensions.cs | 1 + .../DTOs/ProductDetailsDto.cs | 18 ++++ .../DTOs/ProductSummaryDto.cs | 14 +++ .../Interfaces/IApplicationDbContext.cs | 8 ++ .../Interfaces/IProductService.cs | 10 ++ .../Services/ProductService.cs | 90 +++++++++++++++++ .../Controllers/ProductController.cs | 63 ++++++++++++ .../Product/ProductDetailsViewModel.cs | 18 ++++ .../Product/ProductSearchViewModel.cs | 21 ++++ src/CartWise.Web/Views/Product/Details.cshtml | 32 +++++++ src/CartWise.Web/Views/Product/Search.cshtml | 40 ++++++++ .../ProductServiceTests.cs | 96 +++++++++++++++++++ .../ProductAccessControlTests.cs | 49 ++++++++++ 14 files changed, 469 insertions(+), 7 deletions(-) create mode 100644 src/CartWise.Application/DTOs/ProductDetailsDto.cs create mode 100644 src/CartWise.Application/DTOs/ProductSummaryDto.cs create mode 100644 src/CartWise.Application/Interfaces/IProductService.cs create mode 100644 src/CartWise.Application/Services/ProductService.cs create mode 100644 src/CartWise.Web/Controllers/ProductController.cs create mode 100644 src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs create mode 100644 src/CartWise.Web/ViewModels/Product/ProductSearchViewModel.cs create mode 100644 src/CartWise.Web/Views/Product/Details.cshtml create mode 100644 src/CartWise.Web/Views/Product/Search.cshtml create mode 100644 tests/CartWise.Application.Tests/ProductServiceTests.cs create mode 100644 tests/CartWise.Web.Tests/ProductAccessControlTests.cs diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index 3e4e8f7..e567532 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -454,13 +454,15 @@ Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` t - Household price insights can be shown later on the details page **Tasks** -- [ ] Create `IProductService` -- [ ] Implement local catalog search -- [ ] Create `ProductController` -- [ ] Build `Views/Product/Search.cshtml` -- [ ] Build `Views/Product/Details.cshtml` -- [ ] Add product view models -- [ ] Add tests +- [x] Create `IProductService` +- [x] Implement local catalog search +- [x] Create `ProductController` +- [x] Build `Views/Product/Search.cshtml` +- [x] Build `Views/Product/Details.cshtml` +- [x] Add product view models +- [x] Add tests + +**Status:** Done — `IProductService`/`ProductService` return `ProductSummaryDto`/`ProductDetailsDto` (new `CartWise.Application.DTOs`) rather than raw `Product` entities, since search/details need `BrandName`/`ConceptName` resolved via joins that don't belong on the Domain entity itself. Search is a simple `NormalizedName.Contains(...)` match, capped at 25 results. `ProductController` routes (`/products/search`, `/products/{id:guid}`) match AGENTS.md §11 exactly; `Details` 404s for an unknown id. Price/household-insight fields from AGENTS.md §12's `ProductDetailsViewModel` were intentionally omitted — the acceptance criteria itself says "can be shown *later*," and `CW-EPIC-05` (price intelligence) doesn't exist yet; the details page has a placeholder note instead of empty stub fields. 5 application tests + 3 Web tests added. ### `CW-STORY-04.3` Implement barcode lookup flow **Release:** Deferred diff --git a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs index 7112cc7..19c6dd3 100644 --- a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs +++ b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(TimeProvider.System); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/CartWise.Application/DTOs/ProductDetailsDto.cs b/src/CartWise.Application/DTOs/ProductDetailsDto.cs new file mode 100644 index 0000000..3a58b9d --- /dev/null +++ b/src/CartWise.Application/DTOs/ProductDetailsDto.cs @@ -0,0 +1,18 @@ +namespace CartWise.Application.DTOs; + +public class ProductDetailsDto +{ + public Guid ProductId { get; init; } + + public string Name { get; init; } = string.Empty; + + public string? BrandName { get; init; } + + public string? ConceptName { get; init; } + + public string? PackageDisplay { get; init; } + + public string? ImageUrl { get; init; } + + public IReadOnlyList BarcodeIdentifiers { get; init; } = []; +} diff --git a/src/CartWise.Application/DTOs/ProductSummaryDto.cs b/src/CartWise.Application/DTOs/ProductSummaryDto.cs new file mode 100644 index 0000000..418c011 --- /dev/null +++ b/src/CartWise.Application/DTOs/ProductSummaryDto.cs @@ -0,0 +1,14 @@ +namespace CartWise.Application.DTOs; + +public class ProductSummaryDto +{ + public Guid ProductId { get; init; } + + public string Name { get; init; } = string.Empty; + + public string? BrandName { get; init; } + + public string? PackageDisplay { get; init; } + + public string? ImageUrl { get; init; } +} diff --git a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs index 8fe358f..768339b 100644 --- a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs +++ b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs @@ -14,6 +14,14 @@ public interface IApplicationDbContext DbSet ShoppingListItems { get; } + DbSet GroceryConcepts { get; } + + DbSet Brands { get; } + + DbSet Products { get; } + + DbSet ProductIdentifiers { get; } + EntityEntry Entry(TEntity entity) where TEntity : class; Task SaveChangesAsync(CancellationToken cancellationToken = default); diff --git a/src/CartWise.Application/Interfaces/IProductService.cs b/src/CartWise.Application/Interfaces/IProductService.cs new file mode 100644 index 0000000..eeaadf7 --- /dev/null +++ b/src/CartWise.Application/Interfaces/IProductService.cs @@ -0,0 +1,10 @@ +using CartWise.Application.DTOs; + +namespace CartWise.Application.Interfaces; + +public interface IProductService +{ + Task> SearchAsync(string query, CancellationToken cancellationToken = default); + + Task GetDetailsAsync(Guid productId, CancellationToken cancellationToken = default); +} diff --git a/src/CartWise.Application/Services/ProductService.cs b/src/CartWise.Application/Services/ProductService.cs new file mode 100644 index 0000000..975b19f --- /dev/null +++ b/src/CartWise.Application/Services/ProductService.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using CartWise.Application.DTOs; +using CartWise.Application.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Services; + +public class ProductService : IProductService +{ + private readonly IApplicationDbContext _db; + + public ProductService(IApplicationDbContext db) + { + _db = db; + } + + public async Task> SearchAsync(string query, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + + var normalizedQuery = query.Trim().ToUpperInvariant(); + + var rows = await ( + from product in _db.Products + join brand in _db.Brands on product.BrandId equals brand.BrandId into brandJoin + from brand in brandJoin.DefaultIfEmpty() + where product.NormalizedName.Contains(normalizedQuery) + orderby product.Name + select new { product, BrandName = brand != null ? brand.Name : null } + ) + .AsNoTracking() + .Take(25) + .ToListAsync(cancellationToken); + + return rows.Select(row => new ProductSummaryDto + { + ProductId = row.product.ProductId, + Name = row.product.Name, + BrandName = row.BrandName, + PackageDisplay = FormatPackage(row.product.SizeValue, row.product.SizeUnit), + ImageUrl = row.product.ImageUrl + }).ToList(); + } + + public async Task GetDetailsAsync(Guid productId, CancellationToken cancellationToken = default) + { + var product = await _db.Products + .Include(p => p.Identifiers) + .AsNoTracking() + .FirstOrDefaultAsync(p => p.ProductId == productId, cancellationToken); + + if (product is null) + { + return null; + } + + var brandName = product.BrandId is null + ? null + : await _db.Brands.AsNoTracking().Where(b => b.BrandId == product.BrandId).Select(b => b.Name).FirstOrDefaultAsync(cancellationToken); + + var conceptName = product.GroceryConceptId is null + ? null + : await _db.GroceryConcepts.AsNoTracking().Where(c => c.GroceryConceptId == product.GroceryConceptId).Select(c => c.Name).FirstOrDefaultAsync(cancellationToken); + + return new ProductDetailsDto + { + ProductId = product.ProductId, + Name = product.Name, + BrandName = brandName, + ConceptName = conceptName, + PackageDisplay = FormatPackage(product.SizeValue, product.SizeUnit), + ImageUrl = product.ImageUrl, + BarcodeIdentifiers = product.Identifiers.Select(i => i.Value).ToList() + }; + } + + private static string? FormatPackage(decimal? sizeValue, string? sizeUnit) + { + if (sizeValue is null) + { + return sizeUnit; + } + + var text = sizeValue.Value.ToString("0.###", CultureInfo.InvariantCulture); + return sizeUnit is null ? text : $"{text} {sizeUnit}"; + } +} diff --git a/src/CartWise.Web/Controllers/ProductController.cs b/src/CartWise.Web/Controllers/ProductController.cs new file mode 100644 index 0000000..6068101 --- /dev/null +++ b/src/CartWise.Web/Controllers/ProductController.cs @@ -0,0 +1,63 @@ +using CartWise.Application.Interfaces; +using CartWise.Web.ViewModels.Product; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace CartWise.Web.Controllers; + +[Authorize] +public class ProductController : Controller +{ + private readonly IProductService _productService; + + public ProductController(IProductService productService) + { + _productService = productService; + } + + [HttpGet("products/search")] + public async Task Search(string? q) + { + var results = string.IsNullOrWhiteSpace(q) + ? [] + : await _productService.SearchAsync(q); + + var viewModel = new ProductSearchViewModel + { + Query = q, + Results = results.Select(r => new ProductSearchResultViewModel + { + ProductId = r.ProductId, + Name = r.Name, + BrandName = r.BrandName, + PackageDisplay = r.PackageDisplay, + ImageUrl = r.ImageUrl + }).ToList() + }; + + return View(viewModel); + } + + [HttpGet("products/{id:guid}")] + public async Task Details(Guid id) + { + var details = await _productService.GetDetailsAsync(id); + if (details is null) + { + return NotFound(); + } + + var viewModel = new ProductDetailsViewModel + { + ProductId = details.ProductId, + Name = details.Name, + BrandName = details.BrandName, + ConceptName = details.ConceptName, + PackageDisplay = details.PackageDisplay, + ImageUrl = details.ImageUrl, + BarcodeIdentifiers = details.BarcodeIdentifiers.ToList() + }; + + return View(viewModel); + } +} diff --git a/src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs b/src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs new file mode 100644 index 0000000..83c2ec5 --- /dev/null +++ b/src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs @@ -0,0 +1,18 @@ +namespace CartWise.Web.ViewModels.Product; + +public class ProductDetailsViewModel +{ + public Guid ProductId { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? BrandName { get; set; } + + public string? ConceptName { get; set; } + + public string? PackageDisplay { get; set; } + + public string? ImageUrl { get; set; } + + public List BarcodeIdentifiers { get; set; } = []; +} diff --git a/src/CartWise.Web/ViewModels/Product/ProductSearchViewModel.cs b/src/CartWise.Web/ViewModels/Product/ProductSearchViewModel.cs new file mode 100644 index 0000000..75f2beb --- /dev/null +++ b/src/CartWise.Web/ViewModels/Product/ProductSearchViewModel.cs @@ -0,0 +1,21 @@ +namespace CartWise.Web.ViewModels.Product; + +public class ProductSearchViewModel +{ + public string? Query { get; set; } + + public List Results { get; set; } = []; +} + +public class ProductSearchResultViewModel +{ + public Guid ProductId { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? BrandName { get; set; } + + public string? PackageDisplay { get; set; } + + public string? ImageUrl { get; set; } +} diff --git a/src/CartWise.Web/Views/Product/Details.cshtml b/src/CartWise.Web/Views/Product/Details.cshtml new file mode 100644 index 0000000..81bc92c --- /dev/null +++ b/src/CartWise.Web/Views/Product/Details.cshtml @@ -0,0 +1,32 @@ +@model CartWise.Web.ViewModels.Product.ProductDetailsViewModel +@{ + ViewData["Title"] = Model.Name; +} + +

@Model.Name

+ +@if (!string.IsNullOrEmpty(Model.BrandName)) +{ +

@Model.BrandName

+} +@if (!string.IsNullOrEmpty(Model.ConceptName)) +{ +

Concept: @Model.ConceptName

+} +@if (!string.IsNullOrEmpty(Model.PackageDisplay)) +{ +

Size: @Model.PackageDisplay

+} + +@if (Model.BarcodeIdentifiers.Count > 0) +{ +

Identifiers

+
    + @foreach (var identifier in Model.BarcodeIdentifiers) + { +
  • @identifier
  • + } +
+} + +

Price history and household insights will appear here once purchase tracking is available.

diff --git a/src/CartWise.Web/Views/Product/Search.cshtml b/src/CartWise.Web/Views/Product/Search.cshtml new file mode 100644 index 0000000..f3f5dc7 --- /dev/null +++ b/src/CartWise.Web/Views/Product/Search.cshtml @@ -0,0 +1,40 @@ +@model CartWise.Web.ViewModels.Product.ProductSearchViewModel +@{ + ViewData["Title"] = "Search products"; +} + +

Search products

+ +
+
+ + +
+
+ +
+
+ +@if (!string.IsNullOrWhiteSpace(Model.Query) && Model.Results.Count == 0) +{ +

No products matched "@Model.Query".

+} +else if (Model.Results.Count > 0) +{ +
    + @foreach (var result in Model.Results) + { +
  • + @result.Name + @if (!string.IsNullOrEmpty(result.BrandName)) + { + — @result.BrandName + } + @if (!string.IsNullOrEmpty(result.PackageDisplay)) + { + (@result.PackageDisplay) + } +
  • + } +
+} diff --git a/tests/CartWise.Application.Tests/ProductServiceTests.cs b/tests/CartWise.Application.Tests/ProductServiceTests.cs new file mode 100644 index 0000000..56fd607 --- /dev/null +++ b/tests/CartWise.Application.Tests/ProductServiceTests.cs @@ -0,0 +1,96 @@ +using CartWise.Application.Services; +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; +using CartWise.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Tests; + +public class ProductServiceTests : IDisposable +{ + private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); + + private readonly CartWiseDbContext _db; + private readonly ProductService _sut; + + public ProductServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _db = new CartWiseDbContext(options); + _sut = new ProductService(_db); + } + + public void Dispose() + { + _db.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task SearchAsync_ReturnsMatchingProductsWithBrandName() + { + var brand = new Brand("Jif"); + var product = new Product("Jif Creamy Peanut Butter", ProductSourceType.Manual, Now, brandId: brand.BrandId, sizeValue: 16m, sizeUnit: "oz"); + _db.Brands.Add(brand); + _db.Products.Add(product); + await _db.SaveChangesAsync(); + + var results = await _sut.SearchAsync("peanut"); + + var result = Assert.Single(results); + Assert.Equal("Jif Creamy Peanut Butter", result.Name); + Assert.Equal("Jif", result.BrandName); + Assert.Equal("16 oz", result.PackageDisplay); + } + + [Fact] + public async Task SearchAsync_ReturnsEmptyForBlankQuery() + { + var results = await _sut.SearchAsync(" "); + + Assert.Empty(results); + } + + [Fact] + public async Task SearchAsync_ReturnsEmptyWhenNothingMatches() + { + _db.Products.Add(new Product("Whole Milk", ProductSourceType.Manual, Now)); + await _db.SaveChangesAsync(); + + var results = await _sut.SearchAsync("cereal"); + + Assert.Empty(results); + } + + [Fact] + public async Task GetDetailsAsync_ReturnsNullWhenProductDoesNotExist() + { + var details = await _sut.GetDetailsAsync(Guid.NewGuid()); + + Assert.Null(details); + } + + [Fact] + public async Task GetDetailsAsync_ReturnsBrandConceptAndIdentifiers() + { + var brand = new Brand("Jif"); + var concept = new GroceryConcept("Peanut Butter", Now); + var product = new Product("Jif Creamy 16 oz", ProductSourceType.Manual, Now, groceryConceptId: concept.GroceryConceptId, brandId: brand.BrandId); + product.AddIdentifier(ProductIdentifierType.UpcA, "051500255516"); + + _db.Brands.Add(brand); + _db.GroceryConcepts.Add(concept); + _db.Products.Add(product); + await _db.SaveChangesAsync(); + + var details = await _sut.GetDetailsAsync(product.ProductId); + + Assert.NotNull(details); + Assert.Equal("Jif", details!.BrandName); + Assert.Equal("Peanut Butter", details.ConceptName); + Assert.Equal("051500255516", Assert.Single(details.BarcodeIdentifiers)); + } +} diff --git a/tests/CartWise.Web.Tests/ProductAccessControlTests.cs b/tests/CartWise.Web.Tests/ProductAccessControlTests.cs new file mode 100644 index 0000000..22b2a22 --- /dev/null +++ b/tests/CartWise.Web.Tests/ProductAccessControlTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace CartWise.Web.Tests; + +public class ProductAccessControlTests : IClassFixture +{ + private readonly CartWiseWebApplicationFactory _factory; + + public ProductAccessControlTests(CartWiseWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task AnonymousUser_IsRedirectedToLoginWhenSearchingProducts() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var response = await client.GetAsync("/products/search?q=milk"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Contains("/Account/Login", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task AuthenticatedUser_CanSearchWithNoResults() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "productsearch1@example.com", "Product Searcher"); + + var response = await client.GetAsync("/products/search?q=nonexistentitem"); + + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(); + HtmlAssert.Contains("No products matched", body); + } + + [Fact] + public async Task Details_ReturnsNotFoundForUnknownProduct() + { + var client = _factory.CreateClient(); + await WebTestHelpers.RegisterAsync(client, "productdetails1@example.com", "Product Detailer"); + + var response = await client.GetAsync($"/products/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +}