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 | |||
| **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 | |||
| @@ -11,6 +11,7 @@ public static class ApplicationServiceCollectionExtensions | |||
| services.AddSingleton(TimeProvider.System); | |||
| services.AddScoped<IHouseholdService, HouseholdService>(); | |||
| services.AddScoped<IShoppingListService, ShoppingListService>(); | |||
| services.AddScoped<IProductService, ProductService>(); | |||
| 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<GroceryConcept> GroceryConcepts { get; } | |||
| DbSet<Brand> Brands { get; } | |||
| DbSet<Product> Products { get; } | |||
| DbSet<ProductIdentifier> ProductIdentifiers { get; } | |||
| EntityEntry<TEntity> Entry<TEntity>(TEntity entity) where TEntity : class; | |||
| 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.