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 <noreply@anthropic.com>master
| @@ -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 | - Household price insights can be shown later on the details page | ||||
| **Tasks** | **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 | ### `CW-STORY-04.3` Implement barcode lookup flow | ||||
| **Release:** Deferred | **Release:** Deferred | ||||
| @@ -11,6 +11,7 @@ public static class ApplicationServiceCollectionExtensions | |||||
| services.AddSingleton(TimeProvider.System); | services.AddSingleton(TimeProvider.System); | ||||
| services.AddScoped<IHouseholdService, HouseholdService>(); | services.AddScoped<IHouseholdService, HouseholdService>(); | ||||
| services.AddScoped<IShoppingListService, ShoppingListService>(); | services.AddScoped<IShoppingListService, ShoppingListService>(); | ||||
| services.AddScoped<IProductService, ProductService>(); | |||||
| return services; | return services; | ||||
| } | } | ||||
| @@ -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<string> BarcodeIdentifiers { get; init; } = []; | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| @@ -14,6 +14,14 @@ public interface IApplicationDbContext | |||||
| DbSet<ShoppingListItem> ShoppingListItems { get; } | DbSet<ShoppingListItem> ShoppingListItems { get; } | ||||
| DbSet<GroceryConcept> GroceryConcepts { get; } | |||||
| DbSet<Brand> Brands { get; } | |||||
| DbSet<Product> Products { get; } | |||||
| DbSet<ProductIdentifier> ProductIdentifiers { get; } | |||||
| EntityEntry<TEntity> Entry<TEntity>(TEntity entity) where TEntity : class; | EntityEntry<TEntity> Entry<TEntity>(TEntity entity) where TEntity : class; | ||||
| Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | ||||
| @@ -0,0 +1,10 @@ | |||||
| using CartWise.Application.DTOs; | |||||
| namespace CartWise.Application.Interfaces; | |||||
| public interface IProductService | |||||
| { | |||||
| Task<IReadOnlyList<ProductSummaryDto>> SearchAsync(string query, CancellationToken cancellationToken = default); | |||||
| Task<ProductDetailsDto?> GetDetailsAsync(Guid productId, CancellationToken cancellationToken = default); | |||||
| } | |||||
| @@ -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<IReadOnlyList<ProductSummaryDto>> 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<ProductDetailsDto?> 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}"; | |||||
| } | |||||
| } | |||||
| @@ -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<IActionResult> 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<IActionResult> 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); | |||||
| } | |||||
| } | |||||
| @@ -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<string> BarcodeIdentifiers { get; set; } = []; | |||||
| } | |||||
| @@ -0,0 +1,21 @@ | |||||
| namespace CartWise.Web.ViewModels.Product; | |||||
| public class ProductSearchViewModel | |||||
| { | |||||
| public string? Query { get; set; } | |||||
| public List<ProductSearchResultViewModel> 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; } | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| @model CartWise.Web.ViewModels.Product.ProductDetailsViewModel | |||||
| @{ | |||||
| ViewData["Title"] = Model.Name; | |||||
| } | |||||
| <h1>@Model.Name</h1> | |||||
| @if (!string.IsNullOrEmpty(Model.BrandName)) | |||||
| { | |||||
| <p class="text-muted mb-1">@Model.BrandName</p> | |||||
| } | |||||
| @if (!string.IsNullOrEmpty(Model.ConceptName)) | |||||
| { | |||||
| <p class="text-muted mb-1">Concept: @Model.ConceptName</p> | |||||
| } | |||||
| @if (!string.IsNullOrEmpty(Model.PackageDisplay)) | |||||
| { | |||||
| <p class="text-muted mb-1">Size: @Model.PackageDisplay</p> | |||||
| } | |||||
| @if (Model.BarcodeIdentifiers.Count > 0) | |||||
| { | |||||
| <h2 class="h5 mt-4">Identifiers</h2> | |||||
| <ul> | |||||
| @foreach (var identifier in Model.BarcodeIdentifiers) | |||||
| { | |||||
| <li>@identifier</li> | |||||
| } | |||||
| </ul> | |||||
| } | |||||
| <p class="text-muted mt-4"><em>Price history and household insights will appear here once purchase tracking is available.</em></p> | |||||
| @@ -0,0 +1,40 @@ | |||||
| @model CartWise.Web.ViewModels.Product.ProductSearchViewModel | |||||
| @{ | |||||
| ViewData["Title"] = "Search products"; | |||||
| } | |||||
| <h1>Search products</h1> | |||||
| <form asp-controller="Product" asp-action="Search" method="get" class="row g-2 mb-4"> | |||||
| <div class="col-9 col-sm-8"> | |||||
| <label for="q" class="visually-hidden">Search</label> | |||||
| <input type="text" id="q" name="q" class="form-control" placeholder="Search products" value="@Model.Query" /> | |||||
| </div> | |||||
| <div class="col-3 col-sm-2"> | |||||
| <button type="submit" class="btn btn-primary w-100">Search</button> | |||||
| </div> | |||||
| </form> | |||||
| @if (!string.IsNullOrWhiteSpace(Model.Query) && Model.Results.Count == 0) | |||||
| { | |||||
| <p class="text-muted">No products matched "@Model.Query".</p> | |||||
| } | |||||
| else if (Model.Results.Count > 0) | |||||
| { | |||||
| <ul class="list-group"> | |||||
| @foreach (var result in Model.Results) | |||||
| { | |||||
| <li class="list-group-item"> | |||||
| <a asp-controller="Product" asp-action="Details" asp-route-id="@result.ProductId">@result.Name</a> | |||||
| @if (!string.IsNullOrEmpty(result.BrandName)) | |||||
| { | |||||
| <span class="text-muted"> — @result.BrandName</span> | |||||
| } | |||||
| @if (!string.IsNullOrEmpty(result.PackageDisplay)) | |||||
| { | |||||
| <span class="text-muted"> (@result.PackageDisplay)</span> | |||||
| } | |||||
| </li> | |||||
| } | |||||
| </ul> | |||||
| } | |||||
| @@ -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<CartWiseDbContext>() | |||||
| .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)); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,49 @@ | |||||
| using System.Net; | |||||
| using Microsoft.AspNetCore.Mvc.Testing; | |||||
| namespace CartWise.Web.Tests; | |||||
| public class ProductAccessControlTests : IClassFixture<CartWiseWebApplicationFactory> | |||||
| { | |||||
| 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); | |||||
| } | |||||
| } | |||||
Powered by TurnKey Linux.