From 940d732875d018165e6a1b999c5caa0333556015 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 10 Aug 2026 14:53:41 -0400 Subject: [PATCH] CW-STORY-04.3: implement barcode lookup flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/scrum-backlog.md | 12 +- .../DTOs/ProductLookupResult.cs | 18 +++ .../DTOs/ProductSearchResult.cs | 12 ++ .../Interfaces/IProductDataProvider.cs | 12 ++ .../Interfaces/IProductService.cs | 3 + .../Services/ProductService.cs | 104 +++++++++++++++++- .../FakeProductDataProvider.cs | 27 +++++ .../ProductServiceTests.cs | 65 ++++++++++- 8 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 src/CartWise.Application/DTOs/ProductLookupResult.cs create mode 100644 src/CartWise.Application/DTOs/ProductSearchResult.cs create mode 100644 src/CartWise.Application/Interfaces/IProductDataProvider.cs create mode 100644 tests/CartWise.Application.Tests/FakeProductDataProvider.cs diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index e567532..a831457 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -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` (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 diff --git a/src/CartWise.Application/DTOs/ProductLookupResult.cs b/src/CartWise.Application/DTOs/ProductLookupResult.cs new file mode 100644 index 0000000..09fc45f --- /dev/null +++ b/src/CartWise.Application/DTOs/ProductLookupResult.cs @@ -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; } +} diff --git a/src/CartWise.Application/DTOs/ProductSearchResult.cs b/src/CartWise.Application/DTOs/ProductSearchResult.cs new file mode 100644 index 0000000..f4acb1e --- /dev/null +++ b/src/CartWise.Application/DTOs/ProductSearchResult.cs @@ -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; } +} diff --git a/src/CartWise.Application/Interfaces/IProductDataProvider.cs b/src/CartWise.Application/Interfaces/IProductDataProvider.cs new file mode 100644 index 0000000..875c786 --- /dev/null +++ b/src/CartWise.Application/Interfaces/IProductDataProvider.cs @@ -0,0 +1,12 @@ +using CartWise.Application.DTOs; + +namespace CartWise.Application.Interfaces; + +public interface IProductDataProvider +{ + string Name { get; } + + Task FindByBarcodeAsync(string barcode, CancellationToken cancellationToken = default); + + Task> SearchAsync(string query, CancellationToken cancellationToken = default); +} diff --git a/src/CartWise.Application/Interfaces/IProductService.cs b/src/CartWise.Application/Interfaces/IProductService.cs index eeaadf7..7d766f6 100644 --- a/src/CartWise.Application/Interfaces/IProductService.cs +++ b/src/CartWise.Application/Interfaces/IProductService.cs @@ -1,4 +1,5 @@ using CartWise.Application.DTOs; +using CartWise.Application.Results; namespace CartWise.Application.Interfaces; @@ -7,4 +8,6 @@ public interface IProductService Task> SearchAsync(string query, CancellationToken cancellationToken = default); Task GetDetailsAsync(Guid productId, CancellationToken cancellationToken = default); + + Task> ResolveBarcodeAsync(string barcode, CancellationToken cancellationToken = default); } diff --git a/src/CartWise.Application/Services/ProductService.cs b/src/CartWise.Application/Services/ProductService.cs index 975b19f..c884bfe 100644 --- a/src/CartWise.Application/Services/ProductService.cs +++ b/src/CartWise.Application/Services/ProductService.cs @@ -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 _providers; + private readonly TimeProvider _timeProvider; - public ProductService(IApplicationDbContext db) + public ProductService(IApplicationDbContext db, IEnumerable providers, TimeProvider timeProvider) { _db = db; + _providers = providers; + _timeProvider = timeProvider; } public async Task> SearchAsync(string query, CancellationToken cancellationToken = default) @@ -57,6 +64,94 @@ public class ProductService : IProductService return null; } + return await ToDetailsDtoAsync(product, cancellationToken); + } + + public async Task> ResolveBarcodeAsync(string barcode, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(barcode)) + { + return Result.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.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.Success(details); + } + + return Result.Failure("No product was found for that barcode."); + } + + private async Task 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 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) diff --git a/tests/CartWise.Application.Tests/FakeProductDataProvider.cs b/tests/CartWise.Application.Tests/FakeProductDataProvider.cs new file mode 100644 index 0000000..5649f3b --- /dev/null +++ b/tests/CartWise.Application.Tests/FakeProductDataProvider.cs @@ -0,0 +1,27 @@ +using CartWise.Application.DTOs; +using CartWise.Application.Interfaces; + +namespace CartWise.Application.Tests; + +internal sealed class FakeProductDataProvider : IProductDataProvider +{ + private readonly Dictionary _byBarcode; + + public FakeProductDataProvider(Dictionary byBarcode, string name = "OpenFoodFacts") + { + _byBarcode = byBarcode; + Name = name; + } + + public string Name { get; } + + public Task FindByBarcodeAsync(string barcode, CancellationToken cancellationToken = default) + { + return Task.FromResult(_byBarcode.GetValueOrDefault(barcode)); + } + + public Task> SearchAsync(string query, CancellationToken cancellationToken = default) + { + return Task.FromResult>([]); + } +} diff --git a/tests/CartWise.Application.Tests/ProductServiceTests.cs b/tests/CartWise.Application.Tests/ProductServiceTests.cs index 56fd607..ea2dccd 100644 --- a/tests/CartWise.Application.Tests/ProductServiceTests.cs +++ b/tests/CartWise.Application.Tests/ProductServiceTests.cs @@ -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 + { + ["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()); + 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); + } }