Przeglądaj źródła

CW-STORY-04.2: search and view products

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
Daniel Covington 1 tydzień temu
rodzic
commit
66c1ac1be8
14 zmienionych plików z 469 dodań i 7 usunięć
  1. +9
    -7
      docs/scrum-backlog.md
  2. +1
    -0
      src/CartWise.Application/ApplicationServiceCollectionExtensions.cs
  3. +18
    -0
      src/CartWise.Application/DTOs/ProductDetailsDto.cs
  4. +14
    -0
      src/CartWise.Application/DTOs/ProductSummaryDto.cs
  5. +8
    -0
      src/CartWise.Application/Interfaces/IApplicationDbContext.cs
  6. +10
    -0
      src/CartWise.Application/Interfaces/IProductService.cs
  7. +90
    -0
      src/CartWise.Application/Services/ProductService.cs
  8. +63
    -0
      src/CartWise.Web/Controllers/ProductController.cs
  9. +18
    -0
      src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs
  10. +21
    -0
      src/CartWise.Web/ViewModels/Product/ProductSearchViewModel.cs
  11. +32
    -0
      src/CartWise.Web/Views/Product/Details.cshtml
  12. +40
    -0
      src/CartWise.Web/Views/Product/Search.cshtml
  13. +96
    -0
      tests/CartWise.Application.Tests/ProductServiceTests.cs
  14. +49
    -0
      tests/CartWise.Web.Tests/ProductAccessControlTests.cs

+ 9
- 7
docs/scrum-backlog.md Wyświetl plik

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


+ 1
- 0
src/CartWise.Application/ApplicationServiceCollectionExtensions.cs Wyświetl plik

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


+ 18
- 0
src/CartWise.Application/DTOs/ProductDetailsDto.cs Wyświetl plik

@@ -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; } = [];
}

+ 14
- 0
src/CartWise.Application/DTOs/ProductSummaryDto.cs Wyświetl plik

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

+ 8
- 0
src/CartWise.Application/Interfaces/IApplicationDbContext.cs Wyświetl plik

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


+ 10
- 0
src/CartWise.Application/Interfaces/IProductService.cs Wyświetl plik

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

+ 90
- 0
src/CartWise.Application/Services/ProductService.cs Wyświetl plik

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

+ 63
- 0
src/CartWise.Web/Controllers/ProductController.cs Wyświetl plik

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

+ 18
- 0
src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs Wyświetl plik

@@ -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; } = [];
}

+ 21
- 0
src/CartWise.Web/ViewModels/Product/ProductSearchViewModel.cs Wyświetl plik

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

+ 32
- 0
src/CartWise.Web/Views/Product/Details.cshtml Wyświetl plik

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

+ 40
- 0
src/CartWise.Web/Views/Product/Search.cshtml Wyświetl plik

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

+ 96
- 0
tests/CartWise.Application.Tests/ProductServiceTests.cs Wyświetl plik

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

+ 49
- 0
tests/CartWise.Web.Tests/ProductAccessControlTests.cs Wyświetl plik

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

Ładowanie…
Anuluj
Zapisz

Powered by TurnKey Linux.