Quellcode durchsuchen

CW-STORY-04.3: implement barcode lookup flow

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
Daniel Covington vor 1 Woche
Ursprung
Commit
940d732875
8 geänderte Dateien mit 246 neuen und 7 gelöschten Zeilen
  1. +7
    -5
      docs/scrum-backlog.md
  2. +18
    -0
      src/CartWise.Application/DTOs/ProductLookupResult.cs
  3. +12
    -0
      src/CartWise.Application/DTOs/ProductSearchResult.cs
  4. +12
    -0
      src/CartWise.Application/Interfaces/IProductDataProvider.cs
  5. +3
    -0
      src/CartWise.Application/Interfaces/IProductService.cs
  6. +103
    -1
      src/CartWise.Application/Services/ProductService.cs
  7. +27
    -0
      tests/CartWise.Application.Tests/FakeProductDataProvider.cs
  8. +64
    -1
      tests/CartWise.Application.Tests/ProductServiceTests.cs

+ 7
- 5
docs/scrum-backlog.md Datei anzeigen

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


+ 18
- 0
src/CartWise.Application/DTOs/ProductLookupResult.cs Datei anzeigen

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

+ 12
- 0
src/CartWise.Application/DTOs/ProductSearchResult.cs Datei anzeigen

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

+ 12
- 0
src/CartWise.Application/Interfaces/IProductDataProvider.cs Datei anzeigen

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

+ 3
- 0
src/CartWise.Application/Interfaces/IProductService.cs Datei anzeigen

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

+ 103
- 1
src/CartWise.Application/Services/ProductService.cs Datei anzeigen

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


+ 27
- 0
tests/CartWise.Application.Tests/FakeProductDataProvider.cs Datei anzeigen

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

+ 64
- 1
tests/CartWise.Application.Tests/ProductServiceTests.cs Datei anzeigen

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

Laden…
Abbrechen
Speichern

Powered by TurnKey Linux.