OpenFoodFactsProductDataProvider registered as a typed HttpClient, giving CW-STORY-04.3's barcode flow a real provider. Response models stay internal to Infrastructure; failures are caught narrowly and logged via a source-generated LoggerMessage delegate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -499,11 +499,13 @@ Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` t | |||
| - Provider errors are logged safely | |||
| **Tasks** | |||
| - [ ] Implement `OpenFoodFactsProductDataProvider` | |||
| - [ ] Map provider responses to internal DTOs | |||
| - [ ] Add configuration and secrets support | |||
| - [ ] Add error handling and logging | |||
| - [ ] Add tests with mocked provider behavior | |||
| - [x] Implement `OpenFoodFactsProductDataProvider` | |||
| - [x] Map provider responses to internal DTOs | |||
| - [x] Add configuration and secrets support | |||
| - [x] Add error handling and logging | |||
| - [x] Add tests with mocked provider behavior | |||
| **Status:** Done — `OpenFoodFactsProductDataProvider` (`CartWise.Infrastructure.Integrations.OpenFoodFacts`) is registered as a typed `HttpClient` (`AddHttpClient<IProductDataProvider, OpenFoodFactsProductDataProvider>`), so `CW-STORY-04.3`'s `ProductService.ResolveBarcodeAsync` now actually has a provider in its `IEnumerable<IProductDataProvider>` instead of an empty list. Response models (`OpenFoodFactsResponse`/`OpenFoodFactsProduct`) are `internal` to Infrastructure — never referenced outside it, satisfying "never bind Razor pages directly to provider-specific response models." Base URL is configurable via `OpenFoodFacts:BaseUrl` in `appsettings.json` (no API key needed for OFF's public lookup endpoint, but the config-driven shape is ready for a future key-requiring provider like USDA). HTTP/JSON failures are caught narrowly (`HttpRequestException`, `JsonException`, `TaskCanceledException`) and logged via a source-generated `[LoggerMessage]` delegate rather than the ad-hoc `ILogger.LogWarning(...)` extension — a small perf-conscious pattern worth reusing in `CW-STORY-08.3`. A lightweight quantity parser extracts `SizeValue`/`SizeUnit` from OFF's free-text `quantity` field (e.g. `"16 oz"`), returning `null`/`null` rather than guessing when it doesn't parse. No dedicated Infrastructure test project exists (AGENTS.md's structure only lists Domain/Application/Web test projects), so — consistent with how `HouseholdServiceTests`/`ShoppingListServiceTests` already exercise real Infrastructure code — the 4 new tests live in `CartWise.Application.Tests`, using a hand-rolled fake `HttpMessageHandler` (found/not-found/HTTP-failure/malformed-JSON) rather than pulling in a mocking library. | |||
| ### `CW-STORY-04.5` Build scan page | |||
| **Release:** Deferred | |||
| @@ -12,7 +12,5 @@ public class ProductLookupResult | |||
| public string? ImageUrl { get; init; } | |||
| public string SourceName { get; init; } = string.Empty; | |||
| public string? SourceExternalId { get; init; } | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| using CartWise.Application.Interfaces; | |||
| using CartWise.Infrastructure.Data; | |||
| using CartWise.Infrastructure.Identity; | |||
| using CartWise.Infrastructure.Integrations.OpenFoodFacts; | |||
| using Microsoft.AspNetCore.Identity; | |||
| using Microsoft.EntityFrameworkCore; | |||
| using Microsoft.Extensions.Configuration; | |||
| @@ -25,6 +26,13 @@ public static class InfrastructureServiceCollectionExtensions | |||
| .AddEntityFrameworkStores<CartWiseDbContext>() | |||
| .AddDefaultTokenProviders(); | |||
| var openFoodFactsBaseUrl = configuration["OpenFoodFacts:BaseUrl"] ?? "https://world.openfoodfacts.org/"; | |||
| services.AddHttpClient<IProductDataProvider, OpenFoodFactsProductDataProvider>(client => | |||
| { | |||
| client.BaseAddress = new Uri(openFoodFactsBaseUrl); | |||
| client.Timeout = TimeSpan.FromSeconds(10); | |||
| }); | |||
| return services; | |||
| } | |||
| } | |||
| @@ -0,0 +1,80 @@ | |||
| using System.Net.Http.Json; | |||
| using System.Text.Json; | |||
| using System.Text.RegularExpressions; | |||
| using CartWise.Application.DTOs; | |||
| using CartWise.Application.Interfaces; | |||
| using Microsoft.Extensions.Logging; | |||
| namespace CartWise.Infrastructure.Integrations.OpenFoodFacts; | |||
| public partial class OpenFoodFactsProductDataProvider : IProductDataProvider | |||
| { | |||
| private readonly HttpClient _httpClient; | |||
| private readonly ILogger<OpenFoodFactsProductDataProvider> _logger; | |||
| public OpenFoodFactsProductDataProvider(HttpClient httpClient, ILogger<OpenFoodFactsProductDataProvider> logger) | |||
| { | |||
| _httpClient = httpClient; | |||
| _logger = logger; | |||
| } | |||
| public string Name => "OpenFoodFacts"; | |||
| public async Task<ProductLookupResult?> FindByBarcodeAsync(string barcode, CancellationToken cancellationToken = default) | |||
| { | |||
| try | |||
| { | |||
| var response = await _httpClient.GetFromJsonAsync<OpenFoodFactsResponse>($"api/v2/product/{barcode}.json", cancellationToken); | |||
| if (response is null || response.Status != 1 || response.Product is null || string.IsNullOrWhiteSpace(response.Product.ProductName)) | |||
| { | |||
| return null; | |||
| } | |||
| var (sizeValue, sizeUnit) = ParseQuantity(response.Product.Quantity); | |||
| return new ProductLookupResult | |||
| { | |||
| Name = response.Product.ProductName, | |||
| BrandName = response.Product.Brands?.Split(',').FirstOrDefault()?.Trim(), | |||
| SizeValue = sizeValue, | |||
| SizeUnit = sizeUnit, | |||
| ImageUrl = response.Product.ImageUrl, | |||
| SourceExternalId = response.Product.Code ?? barcode | |||
| }; | |||
| } | |||
| catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException) | |||
| { | |||
| LogLookupFailed(_logger, ex, barcode); | |||
| return null; | |||
| } | |||
| } | |||
| [LoggerMessage(Level = LogLevel.Warning, Message = "Open Food Facts lookup failed for barcode {Barcode}")] | |||
| private static partial void LogLookupFailed(ILogger logger, Exception exception, string barcode); | |||
| public Task<IReadOnlyList<ProductSearchResult>> SearchAsync(string query, CancellationToken cancellationToken = default) | |||
| { | |||
| // Open Food Facts text search isn't wired up for MVP; barcode lookup is the primary flow. | |||
| return Task.FromResult<IReadOnlyList<ProductSearchResult>>([]); | |||
| } | |||
| private static (decimal? Value, string? Unit) ParseQuantity(string? quantity) | |||
| { | |||
| if (string.IsNullOrWhiteSpace(quantity)) | |||
| { | |||
| return (null, null); | |||
| } | |||
| var match = QuantityPattern().Match(quantity); | |||
| if (!match.Success || !decimal.TryParse(match.Groups["value"].Value, out var value)) | |||
| { | |||
| return (null, null); | |||
| } | |||
| return (value, match.Groups["unit"].Value.Trim()); | |||
| } | |||
| [GeneratedRegex(@"^\s*(?<value>\d+(\.\d+)?)\s*(?<unit>[a-zA-Z]+)")] | |||
| private static partial Regex QuantityPattern(); | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| using System.Text.Json.Serialization; | |||
| namespace CartWise.Infrastructure.Integrations.OpenFoodFacts; | |||
| internal sealed class OpenFoodFactsResponse | |||
| { | |||
| [JsonPropertyName("status")] | |||
| public int Status { get; set; } | |||
| [JsonPropertyName("product")] | |||
| public OpenFoodFactsProduct? Product { get; set; } | |||
| } | |||
| internal sealed class OpenFoodFactsProduct | |||
| { | |||
| [JsonPropertyName("product_name")] | |||
| public string? ProductName { get; set; } | |||
| [JsonPropertyName("brands")] | |||
| public string? Brands { get; set; } | |||
| [JsonPropertyName("quantity")] | |||
| public string? Quantity { get; set; } | |||
| [JsonPropertyName("image_url")] | |||
| public string? ImageUrl { get; set; } | |||
| [JsonPropertyName("code")] | |||
| public string? Code { get; set; } | |||
| } | |||
| @@ -2,6 +2,9 @@ | |||
| "ConnectionStrings": { | |||
| "DefaultConnection": "Data Source=App_Data/cartwise.db" | |||
| }, | |||
| "OpenFoodFacts": { | |||
| "BaseUrl": "https://world.openfoodfacts.org/" | |||
| }, | |||
| "Logging": { | |||
| "LogLevel": { | |||
| "Default": "Information", | |||
| @@ -0,0 +1,94 @@ | |||
| using System.Net; | |||
| using CartWise.Infrastructure.Integrations.OpenFoodFacts; | |||
| using Microsoft.Extensions.Logging.Abstractions; | |||
| namespace CartWise.Application.Tests; | |||
| public class OpenFoodFactsProductDataProviderTests | |||
| { | |||
| [Fact] | |||
| public async Task FindByBarcodeAsync_ReturnsMappedResultWhenFound() | |||
| { | |||
| const string json = """ | |||
| { | |||
| "status": 1, | |||
| "product": { | |||
| "product_name": "Peanut Butter", | |||
| "brands": "Jif, Some Co-Brand", | |||
| "quantity": "16 oz", | |||
| "image_url": "https://example.com/image.jpg", | |||
| "code": "051500255516" | |||
| } | |||
| } | |||
| """; | |||
| var provider = CreateProvider(HttpStatusCode.OK, json); | |||
| var result = await provider.FindByBarcodeAsync("051500255516"); | |||
| Assert.NotNull(result); | |||
| Assert.Equal("Peanut Butter", result!.Name); | |||
| Assert.Equal("Jif", result.BrandName); | |||
| Assert.Equal(16m, result.SizeValue); | |||
| Assert.Equal("oz", result.SizeUnit); | |||
| Assert.Equal("051500255516", result.SourceExternalId); | |||
| } | |||
| [Fact] | |||
| public async Task FindByBarcodeAsync_ReturnsNullWhenStatusIsNotFound() | |||
| { | |||
| const string json = """{ "status": 0 }"""; | |||
| var provider = CreateProvider(HttpStatusCode.OK, json); | |||
| var result = await provider.FindByBarcodeAsync("000000000000"); | |||
| Assert.Null(result); | |||
| } | |||
| [Fact] | |||
| public async Task FindByBarcodeAsync_ReturnsNullOnHttpFailure() | |||
| { | |||
| var provider = CreateProvider(HttpStatusCode.InternalServerError, "error"); | |||
| var result = await provider.FindByBarcodeAsync("051500255516"); | |||
| Assert.Null(result); | |||
| } | |||
| [Fact] | |||
| public async Task FindByBarcodeAsync_ReturnsNullOnMalformedJson() | |||
| { | |||
| var provider = CreateProvider(HttpStatusCode.OK, "{not valid json"); | |||
| var result = await provider.FindByBarcodeAsync("051500255516"); | |||
| Assert.Null(result); | |||
| } | |||
| private static OpenFoodFactsProductDataProvider CreateProvider(HttpStatusCode statusCode, string content) | |||
| { | |||
| var handler = new FakeHttpMessageHandler(statusCode, content); | |||
| var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://off.example.com/") }; | |||
| return new OpenFoodFactsProductDataProvider(httpClient, NullLogger<OpenFoodFactsProductDataProvider>.Instance); | |||
| } | |||
| private sealed class FakeHttpMessageHandler : HttpMessageHandler | |||
| { | |||
| private readonly HttpStatusCode _statusCode; | |||
| private readonly string _content; | |||
| public FakeHttpMessageHandler(HttpStatusCode statusCode, string content) | |||
| { | |||
| _statusCode = statusCode; | |||
| _content = content; | |||
| } | |||
| protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | |||
| { | |||
| var response = new HttpResponseMessage(_statusCode) | |||
| { | |||
| Content = new StringContent(_content) | |||
| }; | |||
| return Task.FromResult(response); | |||
| } | |||
| } | |||
| } | |||
Powered by TurnKey Linux.