diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index a831457..415b192 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -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`), so `CW-STORY-04.3`'s `ProductService.ResolveBarcodeAsync` now actually has a provider in its `IEnumerable` 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 diff --git a/src/CartWise.Application/DTOs/ProductLookupResult.cs b/src/CartWise.Application/DTOs/ProductLookupResult.cs index 09fc45f..39ef5ce 100644 --- a/src/CartWise.Application/DTOs/ProductLookupResult.cs +++ b/src/CartWise.Application/DTOs/ProductLookupResult.cs @@ -12,7 +12,5 @@ public class ProductLookupResult public string? ImageUrl { get; init; } - public string SourceName { get; init; } = string.Empty; - public string? SourceExternalId { get; init; } } diff --git a/src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 74fd7ef..c87b0db 100644 --- a/src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -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() .AddDefaultTokenProviders(); + var openFoodFactsBaseUrl = configuration["OpenFoodFacts:BaseUrl"] ?? "https://world.openfoodfacts.org/"; + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(openFoodFactsBaseUrl); + client.Timeout = TimeSpan.FromSeconds(10); + }); + return services; } } diff --git a/src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsProductDataProvider.cs b/src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsProductDataProvider.cs new file mode 100644 index 0000000..6570785 --- /dev/null +++ b/src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsProductDataProvider.cs @@ -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 _logger; + + public OpenFoodFactsProductDataProvider(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public string Name => "OpenFoodFacts"; + + public async Task FindByBarcodeAsync(string barcode, CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetFromJsonAsync($"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> 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>([]); + } + + 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*(?\d+(\.\d+)?)\s*(?[a-zA-Z]+)")] + private static partial Regex QuantityPattern(); +} diff --git a/src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsResponse.cs b/src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsResponse.cs new file mode 100644 index 0000000..26ea45a --- /dev/null +++ b/src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsResponse.cs @@ -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; } +} diff --git a/src/CartWise.Web/appsettings.json b/src/CartWise.Web/appsettings.json index 8ee0c54..bf88f1e 100644 --- a/src/CartWise.Web/appsettings.json +++ b/src/CartWise.Web/appsettings.json @@ -2,6 +2,9 @@ "ConnectionStrings": { "DefaultConnection": "Data Source=App_Data/cartwise.db" }, + "OpenFoodFacts": { + "BaseUrl": "https://world.openfoodfacts.org/" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/CartWise.Application.Tests/OpenFoodFactsProductDataProviderTests.cs b/tests/CartWise.Application.Tests/OpenFoodFactsProductDataProviderTests.cs new file mode 100644 index 0000000..c804f5e --- /dev/null +++ b/tests/CartWise.Application.Tests/OpenFoodFactsProductDataProviderTests.cs @@ -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.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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_content) + }; + return Task.FromResult(response); + } + } +}