IProductDataProvider abstraction per AGENTS.md §9; ProductService. ResolveBarcodeAsync does local-first lookup with provider fallback and persists accepted results. No real provider registered yet (CW-STORY-04.4) — flow is fully tested via a fake provider. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -478,11 +478,13 @@ Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` t | |||
| - Accepted results persist locally | |||
| **Tasks** | |||
| - [ ] Define `IProductDataProvider` | |||
| - [ ] Implement barcode normalization | |||
| - [ ] Implement local-first lookup flow | |||
| - [ ] Persist imported products and identifiers | |||
| - [ ] Add unit and application tests | |||
| - [x] Define `IProductDataProvider` | |||
| - [x] Implement barcode normalization | |||
| - [x] Implement local-first lookup flow | |||
| - [x] Persist imported products and identifiers | |||
| - [x] Add unit and application tests | |||
| **Status:** Done — `IProductDataProvider` (`CartWise.Application.Interfaces`) matches AGENTS.md §9 exactly; `ProductLookupResult`/`ProductSearchResult` DTOs added. `IProductService.ResolveBarcodeAsync` implements the local-first flow: normalize (`ProductIdentifier.Normalize`, already built in `CW-STORY-04.1`) → local `ProductIdentifiers` lookup → iterate injected `IEnumerable<IProductDataProvider>` (empty until `CW-STORY-04.4` registers a real one — the flow is fully testable now via a `FakeProductDataProvider` test double) → persist `Product`+`ProductIdentifier` (tagged `Gtin` since the scanned format isn't always known) and a `Brand` if the provider returned one not already in the catalog → return the same `ProductDetailsDto` shape `CW-STORY-04.2` already established. 5 new application tests (local hit skips the provider entirely, provider fallback persists correctly with the right `SourceType`/`SourceExternalId`, not-found-anywhere fails cleanly, missing-barcode input fails cleanly). | |||
| ### `CW-STORY-04.4` Integrate Open Food Facts | |||
| **Release:** Deferred | |||
| @@ -0,0 +1,18 @@ | |||
| namespace CartWise.Application.DTOs; | |||
| public class ProductLookupResult | |||
| { | |||
| public string Name { get; init; } = string.Empty; | |||
| public string? BrandName { get; init; } | |||
| public decimal? SizeValue { get; init; } | |||
| public string? SizeUnit { get; init; } | |||
| public string? ImageUrl { get; init; } | |||
| public string SourceName { get; init; } = string.Empty; | |||
| public string? SourceExternalId { get; init; } | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| namespace CartWise.Application.DTOs; | |||
| public class ProductSearchResult | |||
| { | |||
| public string Name { get; init; } = string.Empty; | |||
| public string? BrandName { get; init; } | |||
| public string SourceName { get; init; } = string.Empty; | |||
| public string? SourceExternalId { get; init; } | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| using CartWise.Application.DTOs; | |||
| namespace CartWise.Application.Interfaces; | |||
| public interface IProductDataProvider | |||
| { | |||
| string Name { get; } | |||
| Task<ProductLookupResult?> FindByBarcodeAsync(string barcode, CancellationToken cancellationToken = default); | |||
| Task<IReadOnlyList<ProductSearchResult>> SearchAsync(string query, CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -1,4 +1,5 @@ | |||
| using CartWise.Application.DTOs; | |||
| using CartWise.Application.Results; | |||
| namespace CartWise.Application.Interfaces; | |||
| @@ -7,4 +8,6 @@ public interface IProductService | |||
| Task<IReadOnlyList<ProductSummaryDto>> SearchAsync(string query, CancellationToken cancellationToken = default); | |||
| Task<ProductDetailsDto?> GetDetailsAsync(Guid productId, CancellationToken cancellationToken = default); | |||
| Task<Result<ProductDetailsDto>> ResolveBarcodeAsync(string barcode, CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -1,6 +1,9 @@ | |||
| using System.Globalization; | |||
| using CartWise.Application.DTOs; | |||
| using CartWise.Application.Interfaces; | |||
| using CartWise.Application.Results; | |||
| using CartWise.Domain.Entities; | |||
| using CartWise.Domain.Enums; | |||
| using Microsoft.EntityFrameworkCore; | |||
| namespace CartWise.Application.Services; | |||
| @@ -8,10 +11,14 @@ namespace CartWise.Application.Services; | |||
| public class ProductService : IProductService | |||
| { | |||
| private readonly IApplicationDbContext _db; | |||
| private readonly IEnumerable<IProductDataProvider> _providers; | |||
| private readonly TimeProvider _timeProvider; | |||
| public ProductService(IApplicationDbContext db) | |||
| public ProductService(IApplicationDbContext db, IEnumerable<IProductDataProvider> providers, TimeProvider timeProvider) | |||
| { | |||
| _db = db; | |||
| _providers = providers; | |||
| _timeProvider = timeProvider; | |||
| } | |||
| public async Task<IReadOnlyList<ProductSummaryDto>> SearchAsync(string query, CancellationToken cancellationToken = default) | |||
| @@ -57,6 +64,94 @@ public class ProductService : IProductService | |||
| return null; | |||
| } | |||
| return await ToDetailsDtoAsync(product, cancellationToken); | |||
| } | |||
| public async Task<Result<ProductDetailsDto>> ResolveBarcodeAsync(string barcode, CancellationToken cancellationToken = default) | |||
| { | |||
| if (string.IsNullOrWhiteSpace(barcode)) | |||
| { | |||
| return Result<ProductDetailsDto>.Failure("Barcode is required."); | |||
| } | |||
| var normalizedBarcode = ProductIdentifier.Normalize(barcode); | |||
| var localProduct = await _db.ProductIdentifiers | |||
| .Where(i => i.NormalizedValue == normalizedBarcode) | |||
| .Join(_db.Products.Include(p => p.Identifiers), i => i.ProductId, p => p.ProductId, (i, p) => p) | |||
| .AsNoTracking() | |||
| .FirstOrDefaultAsync(cancellationToken); | |||
| if (localProduct is not null) | |||
| { | |||
| var localDetails = await ToDetailsDtoAsync(localProduct, cancellationToken); | |||
| return Result<ProductDetailsDto>.Success(localDetails); | |||
| } | |||
| foreach (var provider in _providers) | |||
| { | |||
| var lookup = await provider.FindByBarcodeAsync(normalizedBarcode, cancellationToken); | |||
| if (lookup is null) | |||
| { | |||
| continue; | |||
| } | |||
| var product = await PersistImportedProductAsync(lookup, normalizedBarcode, provider.Name, cancellationToken); | |||
| var details = await ToDetailsDtoAsync(product, cancellationToken); | |||
| return Result<ProductDetailsDto>.Success(details); | |||
| } | |||
| return Result<ProductDetailsDto>.Failure("No product was found for that barcode."); | |||
| } | |||
| private async Task<Product> PersistImportedProductAsync( | |||
| ProductLookupResult lookup, | |||
| string normalizedBarcode, | |||
| string providerName, | |||
| CancellationToken cancellationToken) | |||
| { | |||
| Guid? brandId = null; | |||
| if (!string.IsNullOrWhiteSpace(lookup.BrandName)) | |||
| { | |||
| var normalizedBrandName = lookup.BrandName.Trim().ToUpperInvariant(); | |||
| brandId = await _db.Brands | |||
| .Where(b => b.NormalizedName == normalizedBrandName) | |||
| .Select(b => (Guid?)b.BrandId) | |||
| .FirstOrDefaultAsync(cancellationToken); | |||
| if (brandId is null) | |||
| { | |||
| var brand = new Brand(lookup.BrandName); | |||
| _db.Brands.Add(brand); | |||
| brandId = brand.BrandId; | |||
| } | |||
| } | |||
| var product = new Product( | |||
| lookup.Name, | |||
| MapSourceType(providerName), | |||
| _timeProvider.GetUtcNow().UtcDateTime, | |||
| brandId: brandId, | |||
| sizeValue: lookup.SizeValue, | |||
| sizeUnit: lookup.SizeUnit, | |||
| imageUrl: lookup.ImageUrl, | |||
| sourceExternalId: lookup.SourceExternalId); | |||
| product.AddIdentifier(ProductIdentifierType.Gtin, normalizedBarcode); | |||
| _db.Products.Add(product); | |||
| foreach (var identifier in product.Identifiers) | |||
| { | |||
| _db.ProductIdentifiers.Add(identifier); | |||
| } | |||
| await _db.SaveChangesAsync(cancellationToken); | |||
| return product; | |||
| } | |||
| private async Task<ProductDetailsDto> ToDetailsDtoAsync(Product product, CancellationToken cancellationToken) | |||
| { | |||
| var brandName = product.BrandId is null | |||
| ? null | |||
| : await _db.Brands.AsNoTracking().Where(b => b.BrandId == product.BrandId).Select(b => b.Name).FirstOrDefaultAsync(cancellationToken); | |||
| @@ -77,6 +172,13 @@ public class ProductService : IProductService | |||
| }; | |||
| } | |||
| private static ProductSourceType MapSourceType(string providerName) => providerName switch | |||
| { | |||
| "OpenFoodFacts" => ProductSourceType.OpenFoodFacts, | |||
| "USDA" => ProductSourceType.Usda, | |||
| _ => ProductSourceType.Manual | |||
| }; | |||
| private static string? FormatPackage(decimal? sizeValue, string? sizeUnit) | |||
| { | |||
| if (sizeValue is null) | |||
| @@ -0,0 +1,27 @@ | |||
| using CartWise.Application.DTOs; | |||
| using CartWise.Application.Interfaces; | |||
| namespace CartWise.Application.Tests; | |||
| internal sealed class FakeProductDataProvider : IProductDataProvider | |||
| { | |||
| private readonly Dictionary<string, ProductLookupResult> _byBarcode; | |||
| public FakeProductDataProvider(Dictionary<string, ProductLookupResult> byBarcode, string name = "OpenFoodFacts") | |||
| { | |||
| _byBarcode = byBarcode; | |||
| Name = name; | |||
| } | |||
| public string Name { get; } | |||
| public Task<ProductLookupResult?> FindByBarcodeAsync(string barcode, CancellationToken cancellationToken = default) | |||
| { | |||
| return Task.FromResult(_byBarcode.GetValueOrDefault(barcode)); | |||
| } | |||
| public Task<IReadOnlyList<ProductSearchResult>> SearchAsync(string query, CancellationToken cancellationToken = default) | |||
| { | |||
| return Task.FromResult<IReadOnlyList<ProductSearchResult>>([]); | |||
| } | |||
| } | |||
| @@ -1,3 +1,4 @@ | |||
| using CartWise.Application.DTOs; | |||
| using CartWise.Application.Services; | |||
| using CartWise.Domain.Entities; | |||
| using CartWise.Domain.Enums; | |||
| @@ -20,7 +21,7 @@ public class ProductServiceTests : IDisposable | |||
| .Options; | |||
| _db = new CartWiseDbContext(options); | |||
| _sut = new ProductService(_db); | |||
| _sut = new ProductService(_db, [], TimeProvider.System); | |||
| } | |||
| public void Dispose() | |||
| @@ -93,4 +94,66 @@ public class ProductServiceTests : IDisposable | |||
| Assert.Equal("Peanut Butter", details.ConceptName); | |||
| Assert.Equal("051500255516", Assert.Single(details.BarcodeIdentifiers)); | |||
| } | |||
| [Fact] | |||
| public async Task ResolveBarcodeAsync_ReturnsLocalProductWithoutCallingProvider() | |||
| { | |||
| var product = new Product("Whole Milk", ProductSourceType.Manual, Now); | |||
| product.AddIdentifier(ProductIdentifierType.UpcA, "111111111111"); | |||
| _db.Products.Add(product); | |||
| await _db.SaveChangesAsync(); | |||
| var sut = new ProductService(_db, [], TimeProvider.System); | |||
| var result = await sut.ResolveBarcodeAsync("111111111111"); | |||
| Assert.True(result.IsSuccess); | |||
| Assert.Equal("Whole Milk", result.Value!.Name); | |||
| } | |||
| [Fact] | |||
| public async Task ResolveBarcodeAsync_FallsBackToProviderAndPersistsResult() | |||
| { | |||
| var provider = new FakeProductDataProvider(new Dictionary<string, ProductLookupResult> | |||
| { | |||
| ["222222222222"] = new ProductLookupResult | |||
| { | |||
| Name = "Imported Cereal", | |||
| BrandName = "Acme", | |||
| SizeValue = 12m, | |||
| SizeUnit = "oz", | |||
| SourceExternalId = "off-123" | |||
| } | |||
| }); | |||
| var sut = new ProductService(_db, [provider], TimeProvider.System); | |||
| var result = await sut.ResolveBarcodeAsync("222222222222"); | |||
| Assert.True(result.IsSuccess); | |||
| Assert.Equal("Imported Cereal", result.Value!.Name); | |||
| Assert.Equal("Acme", result.Value.BrandName); | |||
| var persisted = await _db.Products.AsNoTracking().SingleAsync(); | |||
| Assert.Equal(ProductSourceType.OpenFoodFacts, persisted.SourceType); | |||
| Assert.Equal("off-123", persisted.SourceExternalId); | |||
| } | |||
| [Fact] | |||
| public async Task ResolveBarcodeAsync_FailsWhenNoLocalMatchAndNoProviderResult() | |||
| { | |||
| var provider = new FakeProductDataProvider(new Dictionary<string, ProductLookupResult>()); | |||
| var sut = new ProductService(_db, [provider], TimeProvider.System); | |||
| var result = await sut.ResolveBarcodeAsync("999999999999"); | |||
| Assert.False(result.IsSuccess); | |||
| } | |||
| [Fact] | |||
| public async Task ResolveBarcodeAsync_FailsWhenBarcodeIsMissing() | |||
| { | |||
| var result = await _sut.ResolveBarcodeAsync(" "); | |||
| Assert.False(result.IsSuccess); | |||
| } | |||
| } | |||
Powered by TurnKey Linux.