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 | - Provider errors are logged safely | ||||
| **Tasks** | **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 | ### `CW-STORY-04.5` Build scan page | ||||
| **Release:** Deferred | **Release:** Deferred | ||||
| @@ -12,7 +12,5 @@ public class ProductLookupResult | |||||
| public string? ImageUrl { get; init; } | public string? ImageUrl { get; init; } | ||||
| public string SourceName { get; init; } = string.Empty; | |||||
| public string? SourceExternalId { get; init; } | public string? SourceExternalId { get; init; } | ||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| using CartWise.Application.Interfaces; | using CartWise.Application.Interfaces; | ||||
| using CartWise.Infrastructure.Data; | using CartWise.Infrastructure.Data; | ||||
| using CartWise.Infrastructure.Identity; | using CartWise.Infrastructure.Identity; | ||||
| using CartWise.Infrastructure.Integrations.OpenFoodFacts; | |||||
| using Microsoft.AspNetCore.Identity; | using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Configuration; | using Microsoft.Extensions.Configuration; | ||||
| @@ -25,6 +26,13 @@ public static class InfrastructureServiceCollectionExtensions | |||||
| .AddEntityFrameworkStores<CartWiseDbContext>() | .AddEntityFrameworkStores<CartWiseDbContext>() | ||||
| .AddDefaultTokenProviders(); | .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; | 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": { | "ConnectionStrings": { | ||||
| "DefaultConnection": "Data Source=App_Data/cartwise.db" | "DefaultConnection": "Data Source=App_Data/cartwise.db" | ||||
| }, | }, | ||||
| "OpenFoodFacts": { | |||||
| "BaseUrl": "https://world.openfoodfacts.org/" | |||||
| }, | |||||
| "Logging": { | "Logging": { | ||||
| "LogLevel": { | "LogLevel": { | ||||
| "Default": "Information", | "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.