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 | - Accepted results persist locally | ||||
| **Tasks** | **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 | ### `CW-STORY-04.4` Integrate Open Food Facts | ||||
| **Release:** Deferred | **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.DTOs; | ||||
| using CartWise.Application.Results; | |||||
| namespace CartWise.Application.Interfaces; | namespace CartWise.Application.Interfaces; | ||||
| @@ -7,4 +8,6 @@ public interface IProductService | |||||
| Task<IReadOnlyList<ProductSummaryDto>> SearchAsync(string query, CancellationToken cancellationToken = default); | Task<IReadOnlyList<ProductSummaryDto>> SearchAsync(string query, CancellationToken cancellationToken = default); | ||||
| Task<ProductDetailsDto?> GetDetailsAsync(Guid productId, 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 System.Globalization; | ||||
| using CartWise.Application.DTOs; | using CartWise.Application.DTOs; | ||||
| using CartWise.Application.Interfaces; | using CartWise.Application.Interfaces; | ||||
| using CartWise.Application.Results; | |||||
| using CartWise.Domain.Entities; | |||||
| using CartWise.Domain.Enums; | |||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||
| namespace CartWise.Application.Services; | namespace CartWise.Application.Services; | ||||
| @@ -8,10 +11,14 @@ namespace CartWise.Application.Services; | |||||
| public class ProductService : IProductService | public class ProductService : IProductService | ||||
| { | { | ||||
| private readonly IApplicationDbContext _db; | 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; | _db = db; | ||||
| _providers = providers; | |||||
| _timeProvider = timeProvider; | |||||
| } | } | ||||
| public async Task<IReadOnlyList<ProductSummaryDto>> SearchAsync(string query, CancellationToken cancellationToken = default) | public async Task<IReadOnlyList<ProductSummaryDto>> SearchAsync(string query, CancellationToken cancellationToken = default) | ||||
| @@ -57,6 +64,94 @@ public class ProductService : IProductService | |||||
| return null; | 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 | var brandName = product.BrandId is null | ||||
| ? null | ? null | ||||
| : await _db.Brands.AsNoTracking().Where(b => b.BrandId == product.BrandId).Select(b => b.Name).FirstOrDefaultAsync(cancellationToken); | : 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) | private static string? FormatPackage(decimal? sizeValue, string? sizeUnit) | ||||
| { | { | ||||
| if (sizeValue is null) | 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.Application.Services; | ||||
| using CartWise.Domain.Entities; | using CartWise.Domain.Entities; | ||||
| using CartWise.Domain.Enums; | using CartWise.Domain.Enums; | ||||
| @@ -20,7 +21,7 @@ public class ProductServiceTests : IDisposable | |||||
| .Options; | .Options; | ||||
| _db = new CartWiseDbContext(options); | _db = new CartWiseDbContext(options); | ||||
| _sut = new ProductService(_db); | |||||
| _sut = new ProductService(_db, [], TimeProvider.System); | |||||
| } | } | ||||
| public void Dispose() | public void Dispose() | ||||
| @@ -93,4 +94,66 @@ public class ProductServiceTests : IDisposable | |||||
| Assert.Equal("Peanut Butter", details.ConceptName); | Assert.Equal("Peanut Butter", details.ConceptName); | ||||
| Assert.Equal("051500255516", Assert.Single(details.BarcodeIdentifiers)); | 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.