diff --git a/.env.example b/.env.example index f73d292..0ab8727 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,5 @@ POSTGRES_PASSWORD=postgres NEMOTRON_API_KEY= VISION_API_KEY= BLS_API_KEY= +EBAY_CLIENT_ID= +EBAY_CLIENT_SECRET= diff --git a/Features/PhysicalAssets/InventoryList.razor b/Features/PhysicalAssets/InventoryList.razor index 9c821d0..67c535e 100644 --- a/Features/PhysicalAssets/InventoryList.razor +++ b/Features/PhysicalAssets/InventoryList.razor @@ -370,8 +370,8 @@ else } } - // PA10.1/PA10.2: re-runs the (currently AI-estimated, see #138) valuation - // pipeline for this item's existing identification and appends the + // PA10.1/PA10.2: re-runs the market valuation pipeline for this item's + // existing identification and appends the // result as a new history entry - it never overwrites prior valuations. private async Task RevalueAsync() { diff --git a/Features/PhysicalAssets/PhysicalAssets.razor b/Features/PhysicalAssets/PhysicalAssets.razor index c6c4cfa..3657144 100644 --- a/Features/PhysicalAssets/PhysicalAssets.razor +++ b/Features/PhysicalAssets/PhysicalAssets.razor @@ -575,8 +575,8 @@ /// /// In-memory review state for one DetectedAsset: whether the user is - /// keeping it, any brand/model correction, an optional AI-estimated - /// valuation (#138), and (#72-74) whether/where it's been saved to the + /// keeping it, any brand/model correction, an optional market valuation, + /// and (#72-74) whether/where it's been saved to the /// inventory. This is the seam between PA3's detection output and /// PA7's save step. /// diff --git a/HumanCapital/IMarketPotentialPipeline.cs b/HumanCapital/IMarketPotentialPipeline.cs index 16d9809..8ba5f99 100644 --- a/HumanCapital/IMarketPotentialPipeline.cs +++ b/HumanCapital/IMarketPotentialPipeline.cs @@ -14,9 +14,9 @@ namespace MoneyMirror.HumanCapital; /// (#148), whose UI explicitly labels its output "AI estimate - not based /// on live wage data". This isn't an oversight: this method needs a BLS /// series ID per matched occupation, which was meant to come from O*NET -/// occupation data (#93/#94, HC4) - but eBay and O*NET were both dropped -/// from project scope (see #177) before that occupation-to-series mapping -/// was built. Wiring this in requires deciding how to get that mapping +/// occupation data (#93/#94, HC4) - but O*NET was dropped from project scope +/// (see #177) before that occupation-to-series mapping was built. Wiring this +/// in requires deciding how to get that mapping /// without O*NET (a fixed lookup table for common titles? a second LLM /// call to guess a series ID, no longer "no AI in choosing the number"?) /// before this pipeline can replace the AI-guess path in the UI. diff --git a/PhysicalAssets/AssetValuation.cs b/PhysicalAssets/AssetValuation.cs index b8cf731..94a0f9d 100644 --- a/PhysicalAssets/AssetValuation.cs +++ b/PhysicalAssets/AssetValuation.cs @@ -4,11 +4,7 @@ namespace MoneyMirror.PhysicalAssets; /// An estimated resale value for a physical asset. /// /// -/// True when the value came directly from the LLM's -/// judgment, not from real market comps. Never present this to the user -/// (or persist it) as if it were evidence-based - #138 is a deliberate, -/// temporary stand-in for the real PA5 (#14) -> PA6 (#68-71) pipeline, -/// which aggregates real comps deterministically and sets this false. +/// True when the value came from an LLM estimate rather than market listings. /// public record AssetValuation( decimal? EstimatedValueUsd, diff --git a/PhysicalAssets/Configuration/EbayOptions.cs b/PhysicalAssets/Configuration/EbayOptions.cs new file mode 100644 index 0000000..563d316 --- /dev/null +++ b/PhysicalAssets/Configuration/EbayOptions.cs @@ -0,0 +1,19 @@ +namespace MoneyMirror.PhysicalAssets.Configuration; + +/// Client-credentials app keys and Browse API endpoint configuration for eBay. +/// Defaults point at eBay's sandbox environment; swap both URLs to the production hosts +/// once the app has production-approved keys (sandbox listings are seeded test data, not +/// a real catalog). +public sealed class EbayOptions +{ + public const string SectionName = "Ebay"; + + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string AuthUrl { get; set; } = "https://api.sandbox.ebay.com/identity/v1/oauth2/token"; + public string SearchUrl { get; set; } = + "https://api.sandbox.ebay.com/buy/browse/v1/item_summary/search"; + public string MarketplaceId { get; set; } = "EBAY_US"; + public int CacheDurationHours { get; set; } = 720; + public string CacheDirectory { get; set; } = "App_Data/ebay-search-cache"; +} diff --git a/PhysicalAssets/EbayMarketDataException.cs b/PhysicalAssets/EbayMarketDataException.cs new file mode 100644 index 0000000..a1bd216 --- /dev/null +++ b/PhysicalAssets/EbayMarketDataException.cs @@ -0,0 +1,8 @@ +namespace MoneyMirror.PhysicalAssets; + +/// Thrown when eBay's Browse API cannot return comparable listings. +public sealed class EbayMarketDataException : Exception +{ + public EbayMarketDataException(string message, Exception? innerException = null) + : base(message, innerException) { } +} diff --git a/PhysicalAssets/EbayMarketDataService.cs b/PhysicalAssets/EbayMarketDataService.cs new file mode 100644 index 0000000..ceedacd --- /dev/null +++ b/PhysicalAssets/EbayMarketDataService.cs @@ -0,0 +1,298 @@ +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using MoneyMirror.PhysicalAssets.Configuration; + +namespace MoneyMirror.PhysicalAssets; + +/// Searches eBay's Browse API for priced used/refurbished listings via a +/// client-credentials app token. Defaults to eBay's sandbox, whose inventory is seeded +/// test data - it answers real requests but rarely has comps for a real product name, and +/// its own condition filter does not reliably exclude new items, so results are always +/// re-checked against 's USD/condition requirements +/// here rather than trusted as returned. +public sealed class EbayMarketDataService : IMarketDataService +{ + private const int ResultLimit = 50; + private const string ConditionFilter = "conditions:{USED|CERTIFIED_REFURBISHED|SELLER_REFURBISHED}"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly HttpClient _httpClient; + private readonly EbayOptions _options; + private readonly MarketDataSearchCache _searchCache; + private readonly EbayTokenCache _tokenCache; + + public EbayMarketDataService( + HttpClient httpClient, + IOptions options, + MarketDataSearchCache searchCache, + EbayTokenCache tokenCache + ) + { + _httpClient = httpClient; + _options = options.Value; + _searchCache = searchCache; + _tokenCache = tokenCache; + } + + public async Task> SearchUsedListingsAsync( + string label, + string? brand, + string? model, + CancellationToken cancellationToken = default + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(label); + + var query = string.Join( + " ", + new[] { label, brand, model } + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + ); + + var key = BuildCacheKey(query); + var cacheLifetime = TimeSpan.FromHours(Math.Clamp(_options.CacheDurationHours, 1, 24 * 365)); + return await _searchCache.GetOrCreateAsync( + key, + cacheLifetime, + () => SearchProviderAsync(query, cancellationToken), + cancellationToken + ); + } + + private async Task> SearchProviderAsync( + string query, + CancellationToken cancellationToken + ) + { + EnsureCredentials(); + var token = await _tokenCache.GetOrCreateAsync(ct => FetchTokenAsync(ct), cancellationToken); + + using var request = new HttpRequestMessage(HttpMethod.Get, BuildSearchUri(query)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", _options.MarketplaceId); + + using var response = await SendAsync(request, cancellationToken); + var result = await ReadResponseAsync( + response, + "eBay Browse API search", + cancellationToken + ); + + return MapEvidence(result); + } + + private async Task<(string Token, TimeSpan ExpiresIn)> FetchTokenAsync( + CancellationToken cancellationToken + ) + { + using var request = new HttpRequestMessage(HttpMethod.Post, _options.AuthUrl) + { + Content = new FormUrlEncodedContent( + new Dictionary + { + ["grant_type"] = "client_credentials", + ["scope"] = "https://api.ebay.com/oauth/api_scope", + } + ), + }; + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_options.ClientId}:{_options.ClientSecret}") + ); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + + using var response = await SendAsync(request, cancellationToken); + var result = await ReadResponseAsync( + response, + "eBay OAuth token request", + cancellationToken + ); + + if (string.IsNullOrWhiteSpace(result.AccessToken)) + { + throw new EbayMarketDataException("eBay OAuth token response did not include a token."); + } + + return (result.AccessToken, TimeSpan.FromSeconds(Math.Max(result.ExpiresIn, 60))); + } + + private string BuildCacheKey(string query) + { + var normalizedQuery = System.Text.RegularExpressions.Regex + .Replace(query.Normalize(NormalizationForm.FormKC), @"\s+", " ") + .Trim() + .ToLowerInvariant(); + var keyMaterial = string.Join( + "\n", + _options.SearchUrl.Trim(), + _options.MarketplaceId.Trim().ToUpperInvariant(), + _options.CacheDurationHours.ToString(CultureInfo.InvariantCulture), + normalizedQuery + ); + return Convert + .ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(keyMaterial))) + .ToLowerInvariant(); + } + + private void EnsureCredentials() + { + if (string.IsNullOrWhiteSpace(_options.ClientId) || string.IsNullOrWhiteSpace(_options.ClientSecret)) + { + throw new EbayMarketDataException( + "eBay credentials are not configured. Set Ebay:ClientId and Ebay:ClientSecret." + ); + } + } + + private Uri BuildSearchUri(string query) + { + var parameters = new Dictionary + { + ["q"] = query, + ["filter"] = ConditionFilter, + ["limit"] = ResultLimit.ToString(CultureInfo.InvariantCulture), + }; + var queryString = string.Join( + "&", + parameters.Select(pair => + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}" + ) + ); + + return new Uri($"{_options.SearchUrl.TrimEnd('?', '&')}?{queryString}", UriKind.Absolute); + } + + private static IReadOnlyList MapEvidence(SearchResponse result) + { + var evidence = new List(); + + foreach (var item in result.ItemSummaries ?? []) + { + var condition = NormalizeCondition(item.ConditionId); + if ( + condition is null + || string.IsNullOrWhiteSpace(item.Title) + || item.Price is not { Currency: "USD" } price + || !decimal.TryParse( + price.Value, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out var priceUsd + ) + || priceUsd <= 0 + ) + { + continue; + } + + evidence.Add(new AssetValuationEvidence(priceUsd, "eBay", item.Title.Trim(), condition)); + } + + return evidence.Distinct().Take(ResultLimit).ToArray(); + } + + // eBay's documented conditionId taxonomy: 1000-1999 covers New/New-with-defects, 2000-2999 + // is the Refurbished band (certified/seller/excellent/very-good/good), 3000-6999 is Used at + // every grade (Used, Very Good, Good, Acceptable), and 7000 is "for parts or not working" - + // not representative of a working item's resale value, so it is excluded like New. + private static string? NormalizeCondition(string? conditionId) + { + if (!int.TryParse(conditionId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) + { + return null; + } + + return id switch + { + >= 2000 and < 3000 => "Refurbished", + >= 3000 and < 7000 => "Used", + _ => null, + }; + } + + private async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + try + { + return await _httpClient.SendAsync(request, cancellationToken); + } + catch (HttpRequestException ex) + { + throw new EbayMarketDataException("Failed to reach eBay.", ex); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new EbayMarketDataException("eBay request timed out.", ex); + } + } + + private static async Task ReadResponseAsync( + HttpResponseMessage response, + string what, + CancellationToken cancellationToken + ) + { + if (!response.IsSuccessStatusCode) + { + throw new EbayMarketDataException( + $"{what} returned {(int)response.StatusCode} {response.StatusCode}." + ); + } + + try + { + var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return result ?? throw new EbayMarketDataException($"{what} returned an empty response."); + } + catch (JsonException ex) + { + throw new EbayMarketDataException($"Failed to parse the {what} response.", ex); + } + } + + private sealed class TokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; init; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + } + + private sealed class SearchResponse + { + [JsonPropertyName("itemSummaries")] + public IReadOnlyList? ItemSummaries { get; init; } + } + + private sealed class ItemSummary + { + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("price")] + public Money? Price { get; init; } + + [JsonPropertyName("conditionId")] + public string? ConditionId { get; init; } + } + + private sealed class Money + { + [JsonPropertyName("value")] + public string? Value { get; init; } + + [JsonPropertyName("currency")] + public string? Currency { get; init; } + } +} diff --git a/PhysicalAssets/EbayTokenCache.cs b/PhysicalAssets/EbayTokenCache.cs new file mode 100644 index 0000000..2791c6c --- /dev/null +++ b/PhysicalAssets/EbayTokenCache.cs @@ -0,0 +1,45 @@ +namespace MoneyMirror.PhysicalAssets; + +/// Caches eBay's client-credentials OAuth token in memory so every search does not +/// spend a round trip re-authenticating. Registered as a singleton: +/// is a typed HttpClient, which is transient, so a per-instance cache would never be reused. +public sealed class EbayTokenCache(TimeProvider? timeProvider = null) +{ + // A token is refreshed a minute before it actually expires, so a request in flight + // never races an expiring token. + private static readonly TimeSpan ExpiryMargin = TimeSpan.FromMinutes(1); + + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + private readonly SemaphoreSlim _lock = new(1, 1); + private string? _token; + private DateTimeOffset _expiresAtUtc = DateTimeOffset.MinValue; + + public async Task GetOrCreateAsync( + Func> factory, + CancellationToken cancellationToken + ) + { + if (_token is { } cached && _timeProvider.GetUtcNow() < _expiresAtUtc) + { + return cached; + } + + await _lock.WaitAsync(cancellationToken); + try + { + if (_token is { } stillCached && _timeProvider.GetUtcNow() < _expiresAtUtc) + { + return stillCached; + } + + var (token, expiresIn) = await factory(cancellationToken); + _token = token; + _expiresAtUtc = _timeProvider.GetUtcNow() + expiresIn - ExpiryMargin; + return token; + } + finally + { + _lock.Release(); + } + } +} diff --git a/PhysicalAssets/EvidenceBasedAssetValuationService.cs b/PhysicalAssets/EvidenceBasedAssetValuationService.cs new file mode 100644 index 0000000..7938aed --- /dev/null +++ b/PhysicalAssets/EvidenceBasedAssetValuationService.cs @@ -0,0 +1,46 @@ +namespace MoneyMirror.PhysicalAssets; + +/// Builds an asset valuation from structured market listings, without an LLM. +/// Falls back to 's price guess when the market-data +/// provider itself fails (credentials, network, rate limit) - not when it succeeds with zero +/// usable listings, which is already a valid, explicit "no market value" result. +public sealed class EvidenceBasedAssetValuationService : IAssetValuationService +{ + private readonly IMarketDataService _marketDataService; + private readonly AiEstimatedValuationService _fallback; + private readonly TimeProvider _timeProvider; + + public EvidenceBasedAssetValuationService( + IMarketDataService marketDataService, + AiEstimatedValuationService fallback, + TimeProvider? timeProvider = null + ) + { + _marketDataService = marketDataService; + _fallback = fallback; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task EstimateAsync( + string label, + string? brand, + string? model, + CancellationToken cancellationToken = default + ) + { + try + { + var evidence = await _marketDataService.SearchUsedListingsAsync( + label, + brand, + model, + cancellationToken + ); + return MarketValuationCalculator.Calculate(evidence, _timeProvider.GetUtcNow()); + } + catch (EbayMarketDataException) + { + return await _fallback.EstimateAsync(label, brand, model, cancellationToken); + } + } +} diff --git a/PhysicalAssets/IAssetValuationService.cs b/PhysicalAssets/IAssetValuationService.cs index e6df0fc..039ea5b 100644 --- a/PhysicalAssets/IAssetValuationService.cs +++ b/PhysicalAssets/IAssetValuationService.cs @@ -1,19 +1,14 @@ namespace MoneyMirror.PhysicalAssets; /// -/// Estimates a physical asset's resale value. The current implementation -/// (#138) is an explicit MVP placeholder that asks the LLM to guess a -/// plausible value - it is not grounded in real market comps. It exists so -/// the app has an end-to-end demo path before #14 (PA5, real market -/// evidence) and #68-71 (PA6, deterministic aggregation of that evidence) -/// are built; see #138 for the swap-out plan. +/// Estimates a physical asset's resale value from comparable-market evidence. /// public interface IAssetValuationService { /// A valuation whose EstimatedValueUsd is null when no market value is available. /// Callers must preserve that distinction instead of treating it as zero. /// - /// The LLM call failed, or its response couldn't be parsed. + /// The market-data provider failed or its response could not be parsed. /// Task EstimateAsync( string label, diff --git a/PhysicalAssets/IMarketDataService.cs b/PhysicalAssets/IMarketDataService.cs new file mode 100644 index 0000000..232b7a9 --- /dev/null +++ b/PhysicalAssets/IMarketDataService.cs @@ -0,0 +1,13 @@ +namespace MoneyMirror.PhysicalAssets; + +/// Searches an external market-data provider for priced used-item comparables. +public interface IMarketDataService +{ + /// Returns priced used/refurbished listings matching an item's label, brand and model. + Task> SearchUsedListingsAsync( + string label, + string? brand, + string? model, + CancellationToken cancellationToken = default + ); +} diff --git a/PhysicalAssets/MarketDataSearchCache.cs b/PhysicalAssets/MarketDataSearchCache.cs new file mode 100644 index 0000000..bd35d29 --- /dev/null +++ b/PhysicalAssets/MarketDataSearchCache.cs @@ -0,0 +1,155 @@ +using System.Collections.Concurrent; +using System.Text.Json; + +namespace MoneyMirror.PhysicalAssets; + +/// Caches successful market-data provider searches on disk so app restarts do not +/// spend the same search again, and coalesces concurrent identical in-flight searches. +public sealed class MarketDataSearchCache(string cacheDirectory) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ConcurrentDictionary< + string, + Lazy>> + > _inFlight = new(); + + public async Task> GetOrCreateAsync( + string key, + TimeSpan lifetime, + Func>> factory, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var pending = _inFlight.GetOrAdd( + key, + _ => new Lazy>>( + () => LoadOrCreateAsync(key, lifetime, factory), + LazyThreadSafetyMode.ExecutionAndPublication + ) + ); + var task = pending.Value; + _ = task.ContinueWith( + _ => + ( + (ICollection< + KeyValuePair>>> + >) + _inFlight + ).Remove( + new KeyValuePair>>>( + key, + pending + ) + ), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + + return await task.WaitAsync(cancellationToken); + } + + private async Task> LoadOrCreateAsync( + string key, + TimeSpan lifetime, + Func>> factory + ) + { + try + { + Directory.CreateDirectory(cacheDirectory); + } + catch (IOException) + { + return await factory(); + } + catch (UnauthorizedAccessException) + { + return await factory(); + } + + var cachePath = Path.Combine(cacheDirectory, $"{key}.json"); + var cached = await ReadAsync(cachePath); + if ( + cached is { Evidence: not null, ExpiresAtUtc: var expiresAt } + && expiresAt > DateTimeOffset.UtcNow + ) + { + return cached.Evidence; + } + + var evidence = await factory(); + await WriteAsync(cachePath, new CacheEntry(DateTimeOffset.UtcNow.Add(lifetime), evidence)); + return evidence; + } + + private static async Task ReadAsync(string path) + { + try + { + if (!File.Exists(path)) + { + return null; + } + + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + catch (JsonException) + { + return null; + } + } + + private static async Task WriteAsync(string path, CacheEntry entry) + { + var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp"; + try + { + await using (var stream = File.Create(temporaryPath)) + { + await JsonSerializer.SerializeAsync(stream, entry, JsonOptions); + } + + File.Move(temporaryPath, path, overwrite: true); + } + catch (IOException) + { + // The cache is an optimization; a storage hiccup must not fail a valuation. + } + catch (UnauthorizedAccessException) + { + // The cache is an optimization; a storage hiccup must not fail a valuation. + } + finally + { + try + { + File.Delete(temporaryPath); + } + catch (IOException) + { + // Ignore temporary-file cleanup failures. + } + catch (UnauthorizedAccessException) + { + // Ignore temporary-file cleanup failures. + } + } + } + + private sealed record CacheEntry( + DateTimeOffset ExpiresAtUtc, + IReadOnlyList Evidence + ); +} diff --git a/Program.cs b/Program.cs index bc72c5e..17a6527 100644 --- a/Program.cs +++ b/Program.cs @@ -8,6 +8,7 @@ using MoneyMirror.HumanCapital; using MoneyMirror.HumanCapital.Configuration; using MoneyMirror.PhysicalAssets; +using MoneyMirror.PhysicalAssets.Configuration; // Containers start with no LANG/LC_ALL, so .NET falls back to the invariant culture // and renders currency as "ยค" instead of "$". Pin the formatting culture so money @@ -29,6 +30,9 @@ builder.Configuration.GetSection(NemotronOptions.SectionName) ); builder.Services.Configure(builder.Configuration.GetSection(BlsOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(EbayOptions.SectionName) +); builder.Services.Configure( builder.Configuration.GetSection(VisionModelOptions.SectionName) ); @@ -41,6 +45,15 @@ builder.Services.AddTransient(); builder.Services.AddMemoryCache(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + var cacheDirectory = Path.IsPathRooted(options.CacheDirectory) + ? options.CacheDirectory + : Path.Combine(builder.Environment.ContentRootPath, options.CacheDirectory); + return new MarketDataSearchCache(cacheDirectory); +}); // Both AI clients share NVIDIA's endpoint, which sheds load with a 503 when its // workers are saturated - see TransientFaultRetryHandler. HttpClient.Timeout @@ -65,6 +78,13 @@ builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15) ); +builder.Services.AddHttpClient(client => + client.Timeout = TimeSpan.FromSeconds(15) +); + +// eBay's OAuth token is a Bearer header, not a query-string secret, but keep routine +// HTTP logs at Warning so a future log-level bump cannot echo it either. +builder.Logging.AddFilter("System.Net.Http.HttpClient.IMarketDataService", LogLevel.Warning); builder.Services.AddScoped< IMarketPotentialExplanationService, NemotronMarketPotentialExplanationService @@ -83,7 +103,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddScoped(); +// Registered concretely (not just via IAssetValuationService) so +// EvidenceBasedAssetValuationService can take it as its fallback without a +// circular resolution through the interface both of them implement. +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/appsettings.json b/appsettings.json index d8ca2ae..2868530 100644 --- a/appsettings.json +++ b/appsettings.json @@ -24,5 +24,14 @@ "Bls": { "ApiKey": "", "BaseUrl": "https://api.bls.gov/publicAPI/v2/" + }, + "Ebay": { + "ClientId": "", + "ClientSecret": "", + "AuthUrl": "https://api.sandbox.ebay.com/identity/v1/oauth2/token", + "SearchUrl": "https://api.sandbox.ebay.com/buy/browse/v1/item_summary/search", + "MarketplaceId": "EBAY_US", + "CacheDurationHours": 720, + "CacheDirectory": "App_Data/ebay-search-cache" } } diff --git a/docker-compose.yml b/docker-compose.yml index 17172a2..2cf4da3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: Ai__Nemotron__ApiKey: ${NEMOTRON_API_KEY:-} Ai__VisionModel__ApiKey: ${VISION_API_KEY:-} Bls__ApiKey: ${BLS_API_KEY:-} + Ebay__ClientId: ${EBAY_CLIENT_ID:-} + Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-} ports: - "${APP_PORT:-8000}:8080" volumes: diff --git a/docs/development.md b/docs/development.md index c666f4d..2333c63 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,20 +6,44 @@ monolith, one database, one implicit user โ€” no auth or multi-tenancy. ## Configuration -AI and BLS settings live under `Ai:Nemotron`, `Ai:VisionModel`, and `Bls` -(`BaseUrl`, `Model` / `ApiKey` as applicable). `appsettings.json` ships empty -`ApiKey` placeholders โ€” never commit real keys. Set them locally: +AI, BLS, and eBay Browse API settings live under `Ai:Nemotron`, +`Ai:VisionModel`, `Bls`, and `Ebay` (`ClientId`, `ClientSecret`, `AuthUrl`, +`SearchUrl`, `MarketplaceId`, and `CacheDurationHours`). `appsettings.json` +ships empty secret placeholders โ€” never commit real keys. Set them locally: ```sh dotnet user-secrets set "Ai:Nemotron:ApiKey" "" dotnet user-secrets set "Ai:VisionModel:ApiKey" "" dotnet user-secrets set "Bls:ApiKey" "" +dotnet user-secrets set "Ebay:ClientId" "" +dotnet user-secrets set "Ebay:ClientSecret" "" ``` -Or use env vars: `Ai__Nemotron__ApiKey`, `Ai__VisionModel__ApiKey`, `Bls__ApiKey`. - -A free BLS v2 key from [data.bls.gov/registrationEngine](https://data.bls.gov/registrationEngine/) -raises rate limits; the app works without one at the unregistered limit. +Or use env vars: `Ai__Nemotron__ApiKey`, `Ai__VisionModel__ApiKey`, +`Bls__ApiKey`, `Ebay__ClientId`, `Ebay__ClientSecret`. + +Physical asset valuations authenticate to eBay's Browse API with an +OAuth2 client-credentials app token (cached in memory for its ~2-hour +lifetime), then search for up to 50 listings filtered to used/refurbished +condition and calculate a median from USD-priced results. eBay's own +condition filter does not reliably exclude new items, so results are +re-checked client-side against each listing's `conditionId`. No usable +listings produce a null value and an explicit low-confidence explanation. +If eBay itself fails - missing credentials, network error, rate limit - +the valuation falls back to an LLM price guess (`AiEstimatedValuationService`) +rather than failing the request outright. The valuation returns each +comparable's price, source, title, and condition in `AssetValuation.Evidence`; +that evidence is persisted and displayed alongside each valuation in +inventory history. Successful results, including empty results, are cached +for 30 days in `App_Data/ebay-search-cache`, which is on the persistent +Docker app-data volume. Repeated or concurrent searches for the same item +reuse that result. + +The default `AuthUrl`/`SearchUrl` point at eBay's **sandbox** environment, +whose inventory is seeded test data - it answers real requests but rarely +has comps for a real product name. Swap both URLs to the production hosts +(`api.ebay.com` instead of `api.sandbox.ebay.com`) once the app has +production-approved keys from the [eBay Developer Program](https://developer.ebay.com/). ## Run with Docker @@ -37,6 +61,8 @@ on Postgres volumes and host-local `dotnet run`: [backend/postgresql_setup.md](. | `NEMOTRON_API_KEY` | `Ai:Nemotron:ApiKey` | | `VISION_API_KEY` | `Ai:VisionModel:ApiKey` | | `BLS_API_KEY` | `Bls:ApiKey` | +| `EBAY_CLIENT_ID` | `Ebay:ClientId` | +| `EBAY_CLIENT_SECRET` | `Ebay:ClientSecret` | `docker compose down` stops the stack; add `-v` to wipe DB and image volumes. diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/EbayMarketDataServiceTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/EbayMarketDataServiceTests.cs new file mode 100644 index 0000000..0e8f604 --- /dev/null +++ b/tests/MoneyMirror.Tests/PhysicalAssets/EbayMarketDataServiceTests.cs @@ -0,0 +1,353 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Options; +using MoneyMirror.PhysicalAssets; +using MoneyMirror.PhysicalAssets.Configuration; + +namespace MoneyMirror.Tests.PhysicalAssets; + +public class EbayMarketDataServiceTests +{ + private const string TokenResponse = + """{"access_token":"test-access-token","expires_in":7200,"token_type":"Application Access Token"}"""; + + [Fact] + public async Task SearchUsedListingsAsync_AuthenticatesThenSearchesAndMapsOnlyUsedOrRefurbishedUsdResults() + { + using var cache = new TemporarySearchCache(); + RequestSnapshot? tokenRequest = null; + RequestSnapshot? searchRequest = null; + var handler = new CallbackHandler(async (request, cancellationToken) => + { + if (request.RequestUri!.AbsolutePath.Contains("oauth2/token")) + { + tokenRequest = await RequestSnapshot.CaptureAsync(request, cancellationToken); + return JsonResponse(TokenResponse); + } + + searchRequest = await RequestSnapshot.CaptureAsync(request, cancellationToken); + return JsonResponse( + """ + {"itemSummaries":[ + {"title":"Used Fender CD-60S Acoustic Guitar","price":{"value":"130.00","currency":"USD"},"conditionId":"3000"}, + {"title":"New Fender CD-60S Acoustic Guitar","price":{"value":"300.00","currency":"USD"},"conditionId":"1000"}, + {"title":"Certified Refurbished Fender CD-60S","price":{"value":"145.00","currency":"USD"},"conditionId":"2010"}, + {"title":"For parts Fender CD-60S","price":{"value":"40.00","currency":"USD"},"conditionId":"7000"}, + {"title":"Used guitar, foreign listing","price":{"value":"110.00","currency":"GBP"},"conditionId":"3000"} + ]} + """ + ); + }); + var service = CreateService(handler, cache.Create(), new EbayTokenCache()); + + var listings = await service.SearchUsedListingsAsync("acoustic guitar", "Fender", "CD-60S"); + + Assert.Equal(2, listings.Count); + Assert.Contains( + listings, + item => item.PriceUsd == 130m && item.Source == "eBay" && item.Condition == "Used" + ); + Assert.Contains(listings, item => item.PriceUsd == 145m && item.Condition == "Refurbished"); + Assert.All(listings, item => Assert.NotNull(item.ListingTitle)); + + Assert.NotNull(tokenRequest); + Assert.Equal(HttpMethod.Post, tokenRequest.Method); + Assert.StartsWith("Basic ", tokenRequest.Authorization); + Assert.Contains("grant_type=client_credentials", tokenRequest.Body); + + Assert.NotNull(searchRequest); + Assert.Equal(HttpMethod.Get, searchRequest.Method); + Assert.Equal("Bearer test-access-token", searchRequest.Authorization); + Assert.Equal("EBAY_US", searchRequest.MarketplaceId); + Assert.Contains("q=acoustic%20guitar%20Fender%20CD-60S", searchRequest.Uri.Query); + Assert.Contains( + "filter=conditions%3A%7BUSED%7CCERTIFIED_REFURBISHED%7CSELLER_REFURBISHED%7D", + searchRequest.Uri.Query + ); + } + + [Fact] + public async Task SearchUsedListingsAsync_ReusesTheCachedTokenAcrossSearches() + { + using var cache = new TemporarySearchCache(); + var tokenRequests = 0; + var handler = new CallbackHandler((request, _) => + { + if (request.RequestUri!.AbsolutePath.Contains("oauth2/token")) + { + tokenRequests++; + return Task.FromResult(JsonResponse(TokenResponse)); + } + + return Task.FromResult(JsonResponse("""{"itemSummaries":[]}""")); + }); + var tokenCache = new EbayTokenCache(); + var service = CreateService(handler, cache.Create(), tokenCache); + + await service.SearchUsedListingsAsync("lamp", null, null); + await service.SearchUsedListingsAsync("chair", null, null); + + Assert.Equal(1, tokenRequests); + } + + [Fact] + public async Task SearchUsedListingsAsync_EmptyResponseReturnsNoListings() + { + using var cache = new TemporarySearchCache(); + var service = CreateService( + new CallbackHandler((request, _) => + Task.FromResult( + JsonResponse( + request.RequestUri!.AbsolutePath.Contains("oauth2/token") + ? TokenResponse + : "{}" + ) + ) + ), + cache.Create(), + new EbayTokenCache() + ); + + var listings = await service.SearchUsedListingsAsync("lamp", null, null); + + Assert.Empty(listings); + } + + [Fact] + public async Task SearchUsedListingsAsync_SearchApiErrorThrows() + { + using var cache = new TemporarySearchCache(); + var service = CreateService( + new CallbackHandler((request, _) => + Task.FromResult( + request.RequestUri!.AbsolutePath.Contains("oauth2/token") + ? JsonResponse(TokenResponse) + : new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent( + """{"errors":[{"message":"Invalid access token"}]}""", + Encoding.UTF8, + "application/json" + ), + } + ) + ), + cache.Create(), + new EbayTokenCache() + ); + + var exception = await Assert.ThrowsAsync(() => + service.SearchUsedListingsAsync("lamp", null, null) + ); + + Assert.Contains("401", exception.Message); + } + + [Fact] + public async Task SearchUsedListingsAsync_TokenRequestFailureThrowsWithoutExposingTheSecret() + { + using var cache = new TemporarySearchCache(); + var service = CreateService( + new CallbackHandler( + (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)) + ), + cache.Create(), + new EbayTokenCache(), + new EbayOptions + { + ClientId = "test-client-id", + ClientSecret = "super-secret-value", + } + ); + + var exception = await Assert.ThrowsAsync(() => + service.SearchUsedListingsAsync("lamp", null, null) + ); + + Assert.DoesNotContain("super-secret-value", exception.ToString()); + } + + [Fact] + public async Task SearchUsedListingsAsync_MissingCredentialsFailsWithoutCallingProvider() + { + using var cache = new TemporarySearchCache(); + var handler = new CallbackHandler( + (_, _) => throw new InvalidOperationException("Unexpected request.") + ); + var service = CreateService( + handler, + cache.Create(), + new EbayTokenCache(), + new EbayOptions() + ); + + var exception = await Assert.ThrowsAsync(() => + service.SearchUsedListingsAsync("lamp", null, null) + ); + + Assert.Contains("credentials are not configured", exception.Message); + } + + [Fact] + public async Task SearchUsedListingsAsync_ReusesDiskCacheForEquivalentQueriesAcrossServiceInstances() + { + using var cache = new TemporarySearchCache(); + var requestCount = 0; + var firstService = CreateService( + new CallbackHandler((request, _) => + { + requestCount++; + return Task.FromResult( + JsonResponse( + request.RequestUri!.AbsolutePath.Contains("oauth2/token") + ? TokenResponse + : """{"itemSummaries":[{"title":"Used Fender CD-60S guitar","price":{"value":"130.00","currency":"USD"},"conditionId":"3000"}]}""" + ) + ); + }), + cache.Create(), + new EbayTokenCache() + ); + var first = await firstService.SearchUsedListingsAsync("acoustic guitar", "Fender", "CD-60S"); + + // A new cache object simulates an app restart while reading the same persistent + // directory; a fresh token cache too, since it is per-process in production. + var secondService = CreateService( + new CallbackHandler( + (_, _) => throw new InvalidOperationException("A cached search should not call eBay.") + ), + cache.Create(), + new EbayTokenCache() + ); + var second = await secondService.SearchUsedListingsAsync( + " ACOUSTIC GUITAR ", + "fender", + "cd-60s" + ); + + Assert.Equal(2, requestCount); // one token fetch + one search, both on the first service + Assert.Equal(first, second); + } + + [Fact] + public async Task SearchUsedListingsAsync_CoalescesConcurrentIdenticalQueries() + { + using var cache = new TemporarySearchCache(); + var sharedSearchCache = cache.Create(); + var sharedTokenCache = new EbayTokenCache(); + var searchRequests = 0; + var requestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstService = CreateService( + new CallbackHandler(async (request, cancellationToken) => + { + if (request.RequestUri!.AbsolutePath.Contains("oauth2/token")) + { + return JsonResponse(TokenResponse); + } + + Interlocked.Increment(ref searchRequests); + requestStarted.SetResult(); + await releaseRequest.Task.WaitAsync(cancellationToken); + return JsonResponse("""{"itemSummaries":[]}"""); + }), + sharedSearchCache, + sharedTokenCache + ); + var secondService = CreateService( + new CallbackHandler((request, _) => + request.RequestUri!.AbsolutePath.Contains("oauth2/token") + ? Task.FromResult(JsonResponse(TokenResponse)) + : throw new InvalidOperationException("A duplicate search was sent.") + ), + sharedSearchCache, + sharedTokenCache + ); + + var firstTask = firstService.SearchUsedListingsAsync("lamp", null, null); + await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var secondTask = secondService.SearchUsedListingsAsync("lamp", null, null); + releaseRequest.SetResult(); + var results = await Task.WhenAll(firstTask, secondTask); + + Assert.Equal(1, searchRequests); + Assert.Empty(results[0]); + Assert.Empty(results[1]); + } + + private static EbayMarketDataService CreateService( + HttpMessageHandler handler, + MarketDataSearchCache cache, + EbayTokenCache tokenCache, + EbayOptions? options = null + ) => + new( + new HttpClient(handler), + Options.Create( + options + ?? new EbayOptions + { + ClientId = "test-client-id", + ClientSecret = "test-client-secret", + } + ), + cache, + tokenCache + ); + + private static HttpResponseMessage JsonResponse(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private sealed class CallbackHandler( + Func> callback + ) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) => callback(request, cancellationToken); + } + + private sealed class TemporarySearchCache : IDisposable + { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + $"MoneyMirror-EbaySearchCacheTests-{Guid.NewGuid():N}" + ); + + public MarketDataSearchCache Create() => new(_directory); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + } + + private sealed record RequestSnapshot( + HttpMethod Method, + Uri Uri, + string? Authorization, + string? MarketplaceId, + string Body + ) + { + public static async Task CaptureAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) => + new( + request.Method, + request.RequestUri!, + request.Headers.Authorization?.ToString(), + request.Headers.TryGetValues("X-EBAY-C-MARKETPLACE-ID", out var values) + ? values.FirstOrDefault() + : null, + request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken) + ); + } +} diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs index 5fa1b17..6a7d725 100644 --- a/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs +++ b/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs @@ -21,7 +21,9 @@ public EfPhysicalAssetRepositoryTests() _connection = new SqliteConnection("Filename=:memory:"); _connection.Open(); - var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; _db = new MoneyMirrorDbContext(options); _db.Database.EnsureCreated(); @@ -37,7 +39,9 @@ public void Dispose() [Fact] public async Task AddAsync_WithInitialValuation_IsReturnedByGetAllWithThatValuation() { - await _repository.AddAsync(new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m)); + await _repository.AddAsync( + new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m) + ); var all = await _repository.GetAllAsync(); @@ -62,9 +66,14 @@ public async Task AddAsync_WithoutValuation_HasNullCurrentValuation() [Fact] public async Task UpdateAsync_ExistingItem_ChangesItsFields() { - var id = await _repository.AddAsync(new PhysicalAssetInput("Old Name", "Old Category", null, null)); + var id = await _repository.AddAsync( + new PhysicalAssetInput("Old Name", "Old Category", null, null) + ); - var updated = await _repository.UpdateAsync(id, new PhysicalAssetInput("New Name", "New Category", "Model X", null)); + var updated = await _repository.UpdateAsync( + id, + new PhysicalAssetInput("New Name", "New Category", "Model X", null) + ); Assert.True(updated); var asset = Assert.Single(await _repository.GetAllAsync()); @@ -87,7 +96,10 @@ public async Task UpdateAsync_WithNewValuation_UpdatesCurrentValuation() [Fact] public async Task UpdateAsync_UnknownId_ReturnsFalse() { - var updated = await _repository.UpdateAsync(Guid.NewGuid(), new PhysicalAssetInput("X", null, null, null)); + var updated = await _repository.UpdateAsync( + Guid.NewGuid(), + new PhysicalAssetInput("X", null, null, null) + ); Assert.False(updated); } @@ -129,7 +141,15 @@ public async Task GetAllAsync_MultipleItems_SumsToCorrectTotalValue() public async Task AddFromScanAsync_CreatesScannedItemWithValuationAndEvidence() { var id = await _repository.AddFromScanAsync( - new ScannedAssetInput("Guitar", "Music", "Fender CD-60S", "abc123.jpg", 120m, "Typical used price.")); + new ScannedAssetInput( + "Guitar", + "Music", + "Fender CD-60S", + "abc123.jpg", + 120m, + "Typical used price." + ) + ); var summary = Assert.Single(await _repository.GetAllAsync()); Assert.Equal(id, summary.Id); @@ -172,7 +192,12 @@ public async Task AddValuationAsync_ExistingItem_AppendsToHistoryWithoutRemoving { var id = await _repository.AddAsync(new PhysicalAssetInput("Guitar", null, null, 100m)); - var updated = await _repository.AddValuationAsync(id, 175m, "AI estimate (revalue)", "Prices went up."); + var updated = await _repository.AddValuationAsync( + id, + 175m, + "AI estimate (revalue)", + "Prices went up." + ); Assert.True(updated); var detail = await _repository.GetByIdAsync(id); @@ -189,7 +214,12 @@ public async Task AddValuationAsync_ExistingItem_AppendsToHistoryWithoutRemoving [Fact] public async Task AddValuationAsync_UnknownId_ReturnsFalse() { - var updated = await _repository.AddValuationAsync(Guid.NewGuid(), 100m, "AI estimate", null); + var updated = await _repository.AddValuationAsync( + Guid.NewGuid(), + 100m, + "AI estimate", + null + ); Assert.False(updated); } @@ -201,12 +231,22 @@ public async Task AddFromScanAsync_WithComparableListings_PersistsAndReloadsEvid { IReadOnlyList comps = [ - new(115m, "eBay", "Fender CD-60S, used", "Good"), - new(130m, "eBay", "Fender CD-60S dreadnought", null), + new(115m, "Reverb", "Fender CD-60S, used", "Good"), + new(130m, "Marketplace", "Fender CD-60S dreadnought", null), ]; var id = await _repository.AddFromScanAsync( - new ScannedAssetInput("Guitar", "Music", "Fender CD-60S", "abc123.jpg", 122.5m, "Median of 2 listings.", "Market evidence", comps)); + new ScannedAssetInput( + "Guitar", + "Music", + "Fender CD-60S", + "abc123.jpg", + 122.5m, + "Median of 2 listings.", + "Market evidence", + comps + ) + ); var detail = await _repository.GetByIdAsync(id); @@ -214,7 +254,7 @@ public async Task AddFromScanAsync_WithComparableListings_PersistsAndReloadsEvid var entry = Assert.Single(detail!.ValuationHistory); Assert.Equal(2, entry.ComparableListings.Count); Assert.Equal(115m, entry.ComparableListings[0].PriceUsd); - Assert.Equal("eBay", entry.ComparableListings[0].Source); + Assert.Equal("Reverb", entry.ComparableListings[0].Source); Assert.Equal("Fender CD-60S, used", entry.ComparableListings[0].ListingTitle); Assert.Equal("Good", entry.ComparableListings[0].Condition); Assert.Null(entry.ComparableListings[1].Condition); @@ -223,11 +263,25 @@ public async Task AddFromScanAsync_WithComparableListings_PersistsAndReloadsEvid [Fact] public async Task AddValuationAsync_RevalueWithNewEvidence_DoesNotAffectPriorEntrysEvidence() { - IReadOnlyList firstComps = [new(100m, "eBay", null, null)]; - IReadOnlyList secondComps = [new(140m, "eBay", null, null), new(160m, "Craigslist", null, null)]; + IReadOnlyList firstComps = [new(100m, "Reverb", null, null)]; + IReadOnlyList secondComps = + [ + new(140m, "Marketplace", null, null), + new(160m, "Craigslist", null, null), + ]; var id = await _repository.AddFromScanAsync( - new ScannedAssetInput("Guitar", null, null, null, 100m, "First estimate.", "Market evidence", firstComps)); + new ScannedAssetInput( + "Guitar", + null, + null, + null, + 100m, + "First estimate.", + "Market evidence", + firstComps + ) + ); await _repository.AddValuationAsync(id, 150m, "Market evidence", "Revalued.", secondComps); var detail = await _repository.GetByIdAsync(id); @@ -257,9 +311,14 @@ public async Task AddValuationAsync_WithoutComparableListings_LeavesEvidenceEmpt [Fact] public async Task FindPossibleDuplicatesAsync_MatchingProductModel_ReturnsExistingItem() { - await _repository.AddAsync(new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m)); + await _repository.AddAsync( + new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m) + ); - var duplicates = await _repository.FindPossibleDuplicatesAsync("Acoustic guitar", "fender cd-60s"); + var duplicates = await _repository.FindPossibleDuplicatesAsync( + "Acoustic guitar", + "fender cd-60s" + ); var duplicate = Assert.Single(duplicates); Assert.Equal("Guitar", duplicate.Name); @@ -278,7 +337,9 @@ public async Task FindPossibleDuplicatesAsync_MatchingNameOnly_ReturnsExistingIt [Fact] public async Task FindPossibleDuplicatesAsync_NoMatch_ReturnsEmpty() { - await _repository.AddAsync(new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m)); + await _repository.AddAsync( + new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m) + ); var duplicates = await _repository.FindPossibleDuplicatesAsync("Lamp", "IKEA Foto"); @@ -288,7 +349,9 @@ public async Task FindPossibleDuplicatesAsync_NoMatch_ReturnsEmpty() [Fact] public async Task FindPossibleDuplicatesAsync_DifferentProductModelButSameName_StillMatchesOnName() { - await _repository.AddAsync(new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m)); + await _repository.AddAsync( + new PhysicalAssetInput("Guitar", "Music", "Fender CD-60S", 120m) + ); var duplicates = await _repository.FindPossibleDuplicatesAsync("Guitar", "Gibson Les Paul"); diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs new file mode 100644 index 0000000..998e590 --- /dev/null +++ b/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs @@ -0,0 +1,162 @@ +using MoneyMirror.Ai; +using MoneyMirror.PhysicalAssets; + +namespace MoneyMirror.Tests.PhysicalAssets; + +public class EvidenceBasedAssetValuationServiceTests +{ + private static readonly DateTimeOffset ValuationDate = new( + 2026, + 9, + 20, + 12, + 0, + 0, + TimeSpan.Zero + ); + + [Fact] + public async Task EstimateAsync_UsesMarketListingsAndReturnsStructuredEvidence() + { + var source = new FakeMarketDataService([ + new(80m, "Reverb", "Used item A", "Used"), + new(100m, "Craigslist", "Used item B", "Used"), + new(140m, "Marketplace", "Used item C", "Used"), + ]); + var service = new EvidenceBasedAssetValuationService( + source, + UnusedFallback(), + new FixedTimeProvider(ValuationDate) + ); + + var valuation = await service.EstimateAsync("guitar", "Fender", "CD-60S"); + + Assert.Equal(100m, valuation.EstimatedValueUsd); + Assert.Equal(ValuationDate, valuation.ValuationDate); + Assert.False(valuation.IsAiEstimated); + Assert.False(valuation.IsLowConfidence); + Assert.Equal(3, valuation.Evidence.Count); + Assert.Equal("guitar", source.LastLabel); + Assert.Equal("Fender", source.LastBrand); + Assert.Equal("CD-60S", source.LastModel); + } + + [Fact] + public async Task EstimateAsync_NoListingsReturnsExplicitUnavailableLowConfidenceResult() + { + var source = new FakeMarketDataService([]); + var service = new EvidenceBasedAssetValuationService( + source, + UnusedFallback(), + new FixedTimeProvider(ValuationDate) + ); + + var valuation = await service.EstimateAsync("rare item", null, null); + + Assert.Null(valuation.EstimatedValueUsd); + Assert.Empty(valuation.Evidence); + Assert.False(valuation.IsAiEstimated); + Assert.True(valuation.IsLowConfidence); + Assert.Contains("no usable comparable listings", valuation.Reasoning); + } + + [Fact] + public async Task EstimateAsync_ProviderFailureFallsBackToAiEstimate() + { + var source = new FakeMarketDataService( + new EbayMarketDataException("provider unavailable") + ); + var fallback = new AiEstimatedValuationService( + new FakeLlmService( + """{"estimatedValueUsd": 75.00, "reasoning": "A typical used guitar in this condition sells for about $75."}""" + ), + new FixedTimeProvider(ValuationDate) + ); + var service = new EvidenceBasedAssetValuationService( + source, + fallback, + new FixedTimeProvider(ValuationDate) + ); + + var valuation = await service.EstimateAsync("guitar", null, null); + + Assert.Equal(75.00m, valuation.EstimatedValueUsd); + Assert.True(valuation.IsAiEstimated); + Assert.Equal("AI estimate (low confidence; not based on live market data)", valuation.SourceLabel); + Assert.Empty(valuation.Evidence); + } + + [Fact] + public async Task EstimateAsync_ProviderFailureAndFallbackFailureThrowsTheFallbacksException() + { + var source = new FakeMarketDataService( + new EbayMarketDataException("provider unavailable") + ); + var fallback = new AiEstimatedValuationService( + new ThrowingLlmService(), + new FixedTimeProvider(ValuationDate) + ); + var service = new EvidenceBasedAssetValuationService( + source, + fallback, + new FixedTimeProvider(ValuationDate) + ); + + await Assert.ThrowsAsync(() => + service.EstimateAsync("guitar", null, null) + ); + } + + private sealed class FakeMarketDataService(IReadOnlyList result) + : IMarketDataService + { + private readonly Exception? _exception = null; + + public FakeMarketDataService(Exception exception) + : this([]) + { + _exception = exception; + } + + public string? LastLabel { get; private set; } + public string? LastBrand { get; private set; } + public string? LastModel { get; private set; } + + public Task> SearchUsedListingsAsync( + string label, + string? brand, + string? model, + CancellationToken cancellationToken = default + ) + { + LastLabel = label; + LastBrand = brand; + LastModel = model; + return _exception is null + ? Task.FromResult(result) + : Task.FromException>(_exception); + } + } + + private sealed class FakeLlmService(string completion) : ILlmService + { + public Task CompleteAsync(string prompt, CancellationToken cancellationToken = default) => + Task.FromResult(completion); + } + + private sealed class ThrowingLlmService : ILlmService + { + public Task CompleteAsync(string prompt, CancellationToken cancellationToken = default) => + Task.FromException(new LlmServiceException("The LLM is unavailable.")); + } + + // A fallback that must never actually be called - used by tests where the market + // data provider succeeds, so the fallback path should never execute. + private static AiEstimatedValuationService UnusedFallback() => + new(new ThrowingLlmService()); + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +}