diff --git a/.env.example b/.env.example index ddaa166..ffd2245 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,20 @@ JWT_AUDIENCE=LudosData CORS_ORIGIN=http://localhost:8080 # --- Market value (optional) ------------------------------------------------ -# Prices come from eBay's Browse API, which needs a free developer account. +# Three routes, none of which block the others. Nothing here is required: with +# all of it blank, prices can still be imported as a CSV or typed in by hand. +# +# 1. CSV PRICE GUIDE — no account, works immediately. +# POST a CSV to /api/prices/import. Columns are matched by name, so a +# PriceCharting bulk download, a spreadsheet you maintain, or any other list +# all work. Nothing to configure here. +# +# 2. PRICECHARTING — paid, but access is immediate with no review, which makes +# it the practical choice while an eBay keyset is in verification. Token +# comes from the Subscriptions page, "API/Download" button. +PRICECHARTING_TOKEN= + +# 3. EBAY BROWSE — free, but the production keyset needs account verification. # # 1. Register at https://developer.ebay.com and create a developer account # 2. Create an application keyset (Application Keys -> Production) diff --git a/README.md b/README.md index dea4c7e..401315b 100644 --- a/README.md +++ b/README.md @@ -216,43 +216,52 @@ keys: `rating`, `value`, `price`, `purchased`. ### Market value -Prices come from **eBay's Browse API**, which needs a free developer account: -register at developer.ebay.com, create a production application keyset, and put -the App ID and Cert ID in `.env` as `EBAY_CLIENT_ID` / `EBAY_CLIENT_SECRET`. -Without them the pricing endpoints answer 503 with an explanation and nothing -else is affected. +Three routes, deliberately independent, because each is blocked differently. + +| Route | Blocked by | Cost | Basis | +| --- | --- | --- | --- | +| **CSV price guide** | nothing | free | whatever you supply | +| **PriceCharting** | nothing — immediate on subscribing | paid | sale-derived, per condition | +| **eBay Browse** | production keyset needs account verification | free | asking prices, reads high | +| Manual entry | nothing | free | your own judgement | ``` -GET /api/prices/status is a provider configured? -POST /api/prices/refresh {dryRun, limit} price some games +GET /api/prices/status which sources are usable +POST /api/prices/refresh {provider, dryRun} price from a live source +POST /api/prices/import (multipart CSV) apply a price list ``` -**These are asking prices, not sold prices.** Browse returns active listings. -eBay's completed-sales data lives behind the Marketplace Insights API, which is -a limited release closed to new developers, and PriceCharting — the usual -alternative — requires a paid subscription. Asking prices skew high: sellers -list optimistically and unsold listings linger. Treat the numbers as an upper -bound. The provider name (`ebay-asking`) is stored with every value it writes, -so the source is always visible next to the figure. +**The CSV route needs no account and works today.** Column names are matched by +alias, so `product-name` / `console-name` / `loose-price` from a PriceCharting +export and a hand-kept `title,system,loose,cib,new` sheet are both accepted, as +are `$`, thousands separators and blank cells. Rows are matched on title + +system, so the same game on two consoles is priced separately; rows for games +you do not own are reported rather than added. -Deriving a price from listings takes more than an average: +Free sources that do **not** work for this: eBay's completed-sales data sits +behind the Marketplace Insights API, a limited release closed to new +developers; NEXARDA and CheapShark price current retail and digital +storefronts, not collectibles. Scraping PriceCharting violates their terms. -- **Listings are classified into loose / CIB / new** from the title, because a - feed of mixed conditions has no single price. Accessories are discarded - outright — a "box only" listing at $45 counted as a copy would halve the loose - estimate for a $130 cartridge. So are reproductions and multi-game lots. -- **The qualifier is required when discarding.** An early version matched a bare - "box", which threw away "complete in box" and "with box and manual" — most of - the CIB tier — while keeping the cheap box-only listings the filter existed to - remove. Caught by a test asserting on the tier, not on the count. -- **Median, not mean,** with an interquartile trim. One optimist asking 50x drags - a mean past the point of usefulness; a median ignores them. -- **Sample counts travel with the estimate.** A tier from two listings deserves - less confidence than one from thirty. +Deriving a price from eBay listings takes more than an average: + +- **Listings are classified into loose / CIB / new** from their titles, since a + mixed feed has no single price. Accessories are discarded — a "box only" + listing at $45 counted as a copy would halve the loose estimate for a $130 + cartridge — as are reproductions and multi-game lots. +- **The discard qualifier is required.** An early version matched a bare "box", + which threw away "complete in box" and "with box and manual" — most of the CIB + tier — while keeping exactly the listings the filter existed to remove. Caught + by a test asserting on tiers rather than counts. +- **Median with an interquartile trim.** One optimist asking 50x moves a mean + and not a median. +- **Sample counts travel with the estimate**, because a tier drawn from two + listings deserves less confidence than one drawn from thirty. Three prices are stored per game, and `marketValue` is whichever tier matches -that copy's condition. Changing a game's condition re-prices it from the stored -tiers without another lookup. +that copy's condition — so changing a condition re-prices it with no further +lookup. Every value carries the source that wrote it and the moment it was +captured. ### Database changes diff --git a/backend/src/LudosData.Api/Controllers/PricesController.cs b/backend/src/LudosData.Api/Controllers/PricesController.cs index 7f90833..d665e12 100644 --- a/backend/src/LudosData.Api/Controllers/PricesController.cs +++ b/backend/src/LudosData.Api/Controllers/PricesController.cs @@ -1,5 +1,7 @@ +using System.Text; using LudosData.Api.Auth; using LudosData.Api.Data; +using LudosData.Api.Domain; using LudosData.Api.Services.Pricing; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -9,6 +11,9 @@ namespace LudosData.Api.Controllers; public record PriceRefreshRequest { + /// Which source to price from. Defaults to the first configured one. + public string? Provider { get; init; } + /// Limit the run to specific games. Empty means the whole library. public List? GameIds { get; init; } @@ -23,58 +28,75 @@ public record PriceRefreshRequest } public record PriceRefreshItem( - int GameId, - string Title, - decimal? Loose, - decimal? Cib, - decimal? New, - int Samples, - int Discarded, - string? Error); + int GameId, string Title, + decimal? Loose, decimal? Cib, decimal? New, + int Samples, int Discarded, string? Error); public record PriceRefreshResult( - bool DryRun, - string Source, - int Considered, - int Priced, - int Failed, + bool DryRun, string Source, int Considered, int Priced, int Failed, IReadOnlyList Items); +public record ProviderStatus(string Name, bool Configured, string Basis, string? Setup); + +public record PriceImportResult( + bool DryRun, int Rows, int Matched, int Updated, int Unmatched, + IReadOnlyList UnmatchedTitles, IReadOnlyList Problems); + /// -/// Refreshes market values from a price provider. +/// Market values, from whichever sources are configured. /// -/// The figures are asking prices from active listings, not completed sales — -/// see — so they read high. The provider name -/// travels with every value it writes, so the dashboard can say where a number -/// came from and how old it is. +/// Several can coexist because each is blocked in a different way: eBay is free +/// but its production keyset needs account verification, PriceCharting is +/// immediate but paid, and a CSV price guide needs neither. The provider name +/// travels with every value written, so a figure always says where it came from. /// [ApiController] [Route("api/prices")] [Authorize] public class PricesController( LudosDbContext db, - IPriceProvider provider, + IEnumerable providers, ILogger logger) : ControllerBase { - [HttpGet("status")] - public IActionResult Status() => Ok(new + private static readonly Dictionary ProviderNotes = new() { - provider = provider.Name, - configured = provider.IsConfigured, - // Stated plainly so a caller cannot mistake these for sold prices. - basis = "active listing asking prices, not completed sales", - }); + ["ebay-asking"] = ( + "active listing asking prices, not completed sales — expect these to read high", + "Free, but the production keyset needs eBay account verification. " + + "Set EBAY_CLIENT_ID and EBAY_CLIENT_SECRET."), + ["pricecharting"] = ( + "sale-derived prices quoted per condition", + "Paid subscription, but access is immediate with no review. " + + "Set PRICECHARTING_TOKEN."), + }; + + [HttpGet("status")] + public ActionResult> Status() => Ok(providers.Select(p => + { + var notes = ProviderNotes.TryGetValue(p.Name, out var n) + ? n + : ("unspecified", "see documentation"); + + return new ProviderStatus(p.Name, p.IsConfigured, notes.Item1, + p.IsConfigured ? null : notes.Item2); + })); [HttpPost("refresh")] public async Task> Refresh( PriceRefreshRequest request, CancellationToken ct) { - if (!provider.IsConfigured) + var provider = ResolveProvider(request.Provider); + + if (provider is null) { + var available = string.Join(", ", providers.Select(p => p.Name)); return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails { - Title = "No price provider is configured.", - Detail = "Set Ebay:ClientId and Ebay:ClientSecret, then restart the API.", + Title = request.Provider is null + ? "No price provider is configured." + : $"Price provider '{request.Provider}' is not configured.", + Detail = $"Known providers: {available}. Configure one, or import a price " + + "guide CSV at POST /api/prices/import, which needs no account.", }); } @@ -112,12 +134,7 @@ public class PricesController( if (!request.DryRun) { - game.ValueLoose = estimate.Loose; - game.ValueCib = estimate.Cib; - game.ValueNew = estimate.New; - game.RecalculateEffectiveValue(); - game.MarketValueUpdatedAt = DateTimeOffset.UtcNow; - game.MarketValueSource = provider.Name; + ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name); } priced++; @@ -145,4 +162,108 @@ public class PricesController( return Ok(new PriceRefreshResult( request.DryRun, provider.Name, games.Count, priced, failed, items)); } + + /// + /// Applies an external price list. + /// + /// This is the path that needs no account and no approval: export a guide + /// from wherever you have one, or keep a spreadsheet, and the prices land on + /// the matching games. + /// + [HttpPost("import")] + [RequestSizeLimit(32 * 1024 * 1024)] + public async Task> Import( + IFormFile file, + [FromQuery] string source = "price-guide", + [FromQuery] bool dryRun = false, + CancellationToken ct = default) + { + if (file is null || file.Length == 0) + { + return BadRequest(new ProblemDetails { Title = "No file was uploaded." }); + } + + string text; + using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, true)) + { + text = await reader.ReadToEndAsync(ct); + } + + var guide = PriceGuide.Parse(text); + if (guide.Rows.Count == 0) + { + return BadRequest(new ProblemDetails + { + Title = "No usable rows were found.", + Detail = string.Join(" ", guide.Problems), + }); + } + + var ownerId = User.GetUserId(); + var games = await db.Games.Where(g => g.OwnerId == ownerId).ToListAsync(ct); + + // Same key as the library import: a game is a title on a platform, so + // three Donkey Kong Countrys stay three separately priced entries. + var index = games + .GroupBy(g => Key(g.Title, g.System)) + .ToDictionary(g => g.Key, g => g.First()); + + int matched = 0, updated = 0; + var unmatched = new List(); + + foreach (var row in guide.Rows) + { + if (!index.TryGetValue(Key(row.Title, row.System), out var game)) + { + unmatched.Add($"{row.Title}{(row.System is null ? "" : $" ({row.System})")}"); + continue; + } + + matched++; + + if (row.Loose is null && row.Cib is null && row.New is null) + { + continue; + } + + if (!dryRun) + { + ApplyEstimate(game, row.Loose, row.Cib, row.New, source); + } + updated++; + } + + if (!dryRun && updated > 0) + { + await db.SaveChangesAsync(ct); + logger.LogInformation("User {OwnerId} priced {Count} games from a {Source} guide", + ownerId, updated, source); + } + + return Ok(new PriceImportResult( + dryRun, guide.Rows.Count, matched, updated, unmatched.Count, + // Capped: a full guide can miss thousands of rows that are simply + // games this library does not contain. + unmatched.Take(50).ToList(), guide.Problems)); + } + + private IPriceProvider? ResolveProvider(string? name) => + name is null + ? providers.FirstOrDefault(p => p.IsConfigured) + : providers.FirstOrDefault(p => + string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) && p.IsConfigured); + + private static void ApplyEstimate( + Game game, decimal? loose, decimal? cib, decimal? boxed, string source) + { + game.ValueLoose = loose; + game.ValueCib = cib; + game.ValueNew = boxed; + game.RecalculateEffectiveValue(); + game.MarketValueUpdatedAt = DateTimeOffset.UtcNow; + game.MarketValueSource = source; + } + + private static string Key(string title, string? system) => + $"{title.Trim().ToLowerInvariant()} {(system ?? string.Empty).Trim().ToLowerInvariant()}"; } diff --git a/backend/src/LudosData.Api/Program.cs b/backend/src/LudosData.Api/Program.cs index 86d486d..c1ab04c 100644 --- a/backend/src/LudosData.Api/Program.cs +++ b/backend/src/LudosData.Api/Program.cs @@ -117,8 +117,20 @@ builder.Services.AddSingleton(); // Pricing. The provider is registered whether or not credentials are present; // it reports IsConfigured so the endpoint can answer 503 with a useful message // rather than the app failing to start without an optional integration. -builder.Services.Configure(builder.Configuration.GetSection(EbayOptions.SectionName)); +// Providers are registered whether or not credentials are present; each reports +// IsConfigured so the endpoint can explain what is missing rather than the app +// refusing to start without an optional integration. Order is the fallback +// order when no provider is named: PriceCharting first, since it quotes +// sale-derived prices per condition, then eBay's asking prices. +builder.Services.Configure( + builder.Configuration.GetSection(PriceChartingOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(EbayOptions.SectionName)); + +builder.Services.AddHttpClient("pricecharting", client => client.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddHttpClient("ebay", client => client.Timeout = TimeSpan.FromSeconds(30)); + +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs b/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs new file mode 100644 index 0000000..4dbb91d --- /dev/null +++ b/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace LudosData.Api.Services.Pricing; + +public class PriceChartingOptions +{ + public const string SectionName = "PriceCharting"; + + /// + /// Subscription token, from the Subscriptions page of a PriceCharting + /// account (the "API/Download" button). + /// + public string Token { get; set; } = string.Empty; + + public bool IsConfigured => !string.IsNullOrWhiteSpace(Token); +} + +/// +/// Prices from PriceCharting, which quotes loose, complete and new separately — +/// the same three tiers this app stores, so no inference is needed and the +/// numbers are sale-derived rather than asking prices. +/// +/// It needs a paid subscription, but access is immediate on subscribing, with +/// no application or review. That makes it the practical option when an eBay +/// developer account is stuck in verification. +/// +/// One caveat worth knowing when you first run it: PriceCharting's published +/// API documentation is not reachable without an account, so the response shape +/// below follows their widely-used convention — prices as integer pennies under +/// hyphenated keys. is deliberately tolerant and is the only +/// place to adjust if their field names differ from this. +/// +public class PriceChartingProvider( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) : IPriceProvider +{ + private readonly PriceChartingOptions _options = options.Value; + + public string Name => "pricecharting"; + + public bool IsConfigured => _options.IsConfigured; + + /// + /// PriceCharting keys its catalogue by console name, so the query carries + /// one. Their names are spelled out rather than abbreviated. + /// + internal static string ConsoleName(string? system) => (system ?? string.Empty).ToUpperInvariant() switch + { + "NES" => "nes", + "SNES" => "super nintendo", + "N64" => "nintendo 64", + "GC" => "gamecube", + "WII" => "wii", + "GB" => "gameboy", + "GBA" => "gameboy advance", + "DS" => "nintendo ds", + "PS1" => "playstation", + "PS2" => "playstation 2", + "PSP" => "psp", + "360" => "xbox 360", + _ => system?.ToLowerInvariant() ?? string.Empty, + }; + + internal static string BuildQuery(string title, string? system) + { + var console = ConsoleName(system); + return string.IsNullOrWhiteSpace(console) ? title : $"{console} {title}"; + } + + public async Task EstimateAsync( + string title, string? system, CancellationToken ct = default) + { + if (!IsConfigured) + { + throw new InvalidOperationException( + "PriceCharting is not configured. Set PriceCharting:Token."); + } + + var client = httpClientFactory.CreateClient("pricecharting"); + var url = "https://www.pricecharting.com/api/product" + + $"?t={Uri.EscapeDataString(_options.Token)}" + + $"&q={Uri.EscapeDataString(BuildQuery(title, system))}"; + + using var response = await client.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("PriceCharting lookup for {Title} returned {Status}", + title, response.StatusCode); + return PriceEstimate.Empty; + } + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + return Parse(document); + } + + /// + /// Reads the three tiers from a product response. + /// + /// Internal so it can be tested on a fixture without a subscription, and + /// tolerant of both hyphenated and camelCase keys so a naming difference + /// degrades to "no price" rather than a crash. + /// + internal static PriceEstimate Parse(JsonDocument document) + { + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object) + { + return PriceEstimate.Empty; + } + + // A miss is reported in-band with a status field rather than by HTTP. + if (root.TryGetProperty("status", out var status) + && string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase)) + { + return PriceEstimate.Empty; + } + + var loose = ReadPennies(root, "loose-price", "loosePrice"); + var cib = ReadPennies(root, "cib-price", "cibPrice"); + var boxed = ReadPennies(root, "new-price", "newPrice"); + + // A single quoted price per tier, so the sample count is one where a + // price exists — the estimate carries its own confidence either way. + return new PriceEstimate( + loose, cib, boxed, + loose is null ? 0 : 1, + cib is null ? 0 : 1, + boxed is null ? 0 : 1, + 0); + } + + /// Prices arrive as integer pennies; 1250 means $12.50. + private static decimal? ReadPennies(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (!root.TryGetProperty(name, out var element)) + { + continue; + } + + long? pennies = element.ValueKind switch + { + JsonValueKind.Number when element.TryGetInt64(out var value) => value, + JsonValueKind.String when long.TryParse(element.GetString(), out var value) => value, + _ => null, + }; + + // Zero means "no price on record", not "free". + if (pennies is > 0) + { + return pennies.Value / 100m; + } + } + + return null; + } +} diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceGuide.cs b/backend/src/LudosData.Api/Services/Pricing/PriceGuide.cs new file mode 100644 index 0000000..f4fb137 --- /dev/null +++ b/backend/src/LudosData.Api/Services/Pricing/PriceGuide.cs @@ -0,0 +1,136 @@ +using System.Globalization; + +using LudosData.Api.Services; + +namespace LudosData.Api.Services.Pricing; + +/// One row of an external price list. +public record PriceGuideRow( + string Title, + string? System, + decimal? Loose, + decimal? Cib, + decimal? New); + +public record PriceGuideParseResult( + IReadOnlyList Rows, + IReadOnlyList Problems); + +/// +/// Reads a price list out of a CSV. +/// +/// The point is to be source-agnostic. A PriceCharting subscriber's bulk +/// download, a spreadsheet kept by hand, and a list exported from anywhere else +/// all describe the same thing — a title, a platform and some prices — so this +/// accepts the column names each of them tends to use rather than demanding one +/// fixed schema. +/// +public static class PriceGuide +{ + // Header aliases, lowercased and stripped of spaces, underscores and hyphens. + private static readonly string[] TitleNames = + ["title", "productname", "product", "name", "game", "gamename"]; + + private static readonly string[] SystemNames = + ["system", "console", "consolename", "platform"]; + + private static readonly string[] LooseNames = + ["loose", "looseprice", "loosevalue", "cartonly", "value"]; + + private static readonly string[] CibNames = + ["cib", "cibprice", "complete", "completeprice", "cibvalue", "completeinbox"]; + + private static readonly string[] NewNames = + ["new", "newprice", "sealed", "sealedprice", "newvalue", "graded"]; + + private static string Normalise(string header) => + new(header.Trim().ToLowerInvariant() + .Where(c => char.IsLetterOrDigit(c)) + .ToArray()); + + public static PriceGuideParseResult Parse(string text) + { + var problems = new List(); + var rows = Csv.Parse(text); + + if (rows.Count == 0) + { + return new PriceGuideParseResult([], ["The file is empty."]); + } + + var header = rows[0].Select(Normalise).ToList(); + + int Find(string[] names) + { + foreach (var name in names) + { + var at = header.IndexOf(name); + if (at >= 0) return at; + } + return -1; + } + + var titleAt = Find(TitleNames); + if (titleAt < 0) + { + return new PriceGuideParseResult([], + ["No title column found. Expected one of: title, product-name, name, game."]); + } + + var systemAt = Find(SystemNames); + var looseAt = Find(LooseNames); + var cibAt = Find(CibNames); + var newAt = Find(NewNames); + + if (looseAt < 0 && cibAt < 0 && newAt < 0) + { + return new PriceGuideParseResult([], + ["No price column found. Expected at least one of: loose, cib, new."]); + } + + var parsed = new List(); + + for (var i = 1; i < rows.Count; i++) + { + var row = rows[i]; + + string? Cell(int at) => + at >= 0 && at < row.Count && row[at].Trim().Length > 0 ? row[at].Trim() : null; + + var title = Cell(titleAt); + if (title is null) + { + problems.Add($"Row {i + 1}: no title"); + continue; + } + + parsed.Add(new PriceGuideRow( + title, Cell(systemAt), + ParseMoney(Cell(looseAt)), ParseMoney(Cell(cibAt)), ParseMoney(Cell(newAt)))); + } + + return new PriceGuideParseResult(parsed, problems); + } + + /// + /// Tolerant of what a spreadsheet emits: currency symbols, thousands + /// separators, and integer pennies where a guide quotes them that way. + /// + public static decimal? ParseMoney(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty).Trim(); + + if (!decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)) + { + return null; + } + + // Zero means "no price on record", not "free". + return parsed > 0 ? parsed : null; + } +} diff --git a/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs b/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs new file mode 100644 index 0000000..db239d7 --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs @@ -0,0 +1,369 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using LudosData.Api.Services.Pricing; + +namespace LudosData.Api.Tests; + +public class PriceGuideParsingTests +{ + [Fact] + public void A_plain_guide_is_read() + { + const string csv = """ + title,console,loose,cib,new + Chrono Trigger,Super Nintendo,128.00,650.00,12000.00 + Super Metroid,Super Nintendo,55.00,210.00,4000.00 + """; + + var result = PriceGuide.Parse(csv); + + Assert.Equal(2, result.Rows.Count); + Assert.Empty(result.Problems); + + var first = result.Rows[0]; + Assert.Equal("Chrono Trigger", first.Title); + Assert.Equal("Super Nintendo", first.System); + Assert.Equal(128.00m, first.Loose); + Assert.Equal(650.00m, first.Cib); + Assert.Equal(12000.00m, first.New); + } + + [Theory] + // Different exports name the same columns differently; all of these mean + // the same thing, and demanding one schema would make the feature useless + // for whichever guide the user actually has. + [InlineData("product-name,console-name,loose-price,cib-price,new-price")] + [InlineData("Product Name,Console Name,Loose Price,CIB Price,New Price")] + [InlineData("game,platform,loose,complete,sealed")] + [InlineData("NAME,SYSTEM,LOOSE,CIB,NEW")] + public void Column_aliases_and_casing_are_accepted(string header) + { + var result = PriceGuide.Parse($"{header}\nChrono Trigger,SNES,128.00,650.00,12000.00"); + + var row = Assert.Single(result.Rows); + Assert.Equal("Chrono Trigger", row.Title); + Assert.Equal("SNES", row.System); + Assert.Equal(128.00m, row.Loose); + } + + [Theory] + [InlineData("$128.00", 128.00)] + [InlineData("1,234.56", 1234.56)] + [InlineData(" $1,234.56 ", 1234.56)] + [InlineData("128", 128)] + public void Spreadsheet_formatting_is_tolerated(string input, double expected) + { + Assert.Equal((decimal)expected, PriceGuide.ParseMoney(input)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("0")] + [InlineData("0.00")] + [InlineData("n/a")] + public void Blank_and_zero_prices_read_as_no_price(string input) + { + // Zero means "not on record", and treating it as free would drag a + // collection total toward nothing. + Assert.Null(PriceGuide.ParseMoney(input)); + } + + [Fact] + public void A_guide_with_no_title_column_is_rejected_with_a_reason() + { + var result = PriceGuide.Parse("foo,bar\n1,2"); + + Assert.Empty(result.Rows); + Assert.Contains("title", Assert.Single(result.Problems), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A_guide_with_no_price_column_is_rejected_with_a_reason() + { + var result = PriceGuide.Parse("title,console\nChrono Trigger,SNES"); + + Assert.Empty(result.Rows); + Assert.Contains("price", Assert.Single(result.Problems), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A_partial_guide_is_usable() + { + // Plenty of lists quote one condition only. + var result = PriceGuide.Parse("title,system,loose\nChrono Trigger,SNES,128.00"); + + var row = Assert.Single(result.Rows); + Assert.Equal(128.00m, row.Loose); + Assert.Null(row.Cib); + Assert.Null(row.New); + } + + [Fact] + public void Quoted_titles_containing_commas_survive() + { + var result = PriceGuide.Parse("title,system,loose\n\"Spyro 2: Ripto's Rage!, The Best\",PS1,40.00"); + + Assert.Equal("Spyro 2: Ripto's Rage!, The Best", Assert.Single(result.Rows).Title); + } +} + +public class PriceChartingParsingTests +{ + [Fact] + public void Pennies_are_converted_to_currency() + { + using var document = JsonDocument.Parse(""" + { + "status": "success", + "product-name": "Chrono Trigger", + "console-name": "Super Nintendo", + "loose-price": 12800, + "cib-price": 65000, + "new-price": 1200000 + } + """); + + var estimate = PriceChartingProvider.Parse(document); + + Assert.Equal(128.00m, estimate.Loose); + Assert.Equal(650.00m, estimate.Cib); + Assert.Equal(12000.00m, estimate.New); + } + + [Fact] + public void A_zero_price_is_treated_as_absent() + { + using var document = JsonDocument.Parse(""" + { "status": "success", "loose-price": 12800, "cib-price": 0, "new-price": 0 } + """); + + var estimate = PriceChartingProvider.Parse(document); + + Assert.Equal(128.00m, estimate.Loose); + // Zero means no price on record, not a free game. + Assert.Null(estimate.Cib); + Assert.Null(estimate.New); + } + + [Fact] + public void An_error_response_yields_no_prices() + { + using var document = JsonDocument.Parse("""{ "status": "error", "error-message": "not found" }"""); + + Assert.False(PriceChartingProvider.Parse(document).HasAnyPrice); + } + + [Fact] + public void Unexpected_field_names_degrade_to_no_price_rather_than_throwing() + { + // Their docs are not reachable without an account, so a naming + // difference has to be survivable. + using var document = JsonDocument.Parse("""{ "status": "success", "somethingElse": 1 }"""); + + Assert.False(PriceChartingProvider.Parse(document).HasAnyPrice); + } + + [Theory] + [InlineData("SNES", "super nintendo Chrono Trigger")] + [InlineData("N64", "nintendo 64 GoldenEye")] + [InlineData("360", "xbox 360 Halo 3")] + public void The_console_name_leads_the_query(string system, string expected) + { + var title = expected.Split(' ').Last() == "Trigger" ? "Chrono Trigger" + : expected.Contains("GoldenEye") ? "GoldenEye" : "Halo 3"; + + Assert.Equal(expected, PriceChartingProvider.BuildQuery(title, system)); + } +} + +public class PriceEndpointTests(LudosApiFactory factory) : IClassFixture +{ + private static MultipartFormDataContent CsvContent(string body) + { + var content = new MultipartFormDataContent(); + var part = new ByteArrayContent(Encoding.UTF8.GetBytes(body)); + part.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/csv"); + content.Add(part, "file", "guide.csv"); + return content; + } + + private static async Task SeedAsync(HttpClient client) + { + await client.PostJsonAsync("/api/games", new + { + title = "Chrono Trigger", system = "SNES", own = true, condition = "Cib", + }); + await client.PostJsonAsync("/api/games", new + { + title = "Super Metroid", system = "SNES", own = true, condition = "Loose", + }); + } + + [Fact] + public async Task Status_lists_every_provider_and_how_to_enable_it() + { + var client = await factory.CreateUserClientAsync("price-status"); + + var statuses = await client.GetJsonAsync>("/api/prices/status"); + + Assert.Contains(statuses!, s => s.Name == "ebay-asking"); + Assert.Contains(statuses!, s => s.Name == "pricecharting"); + // None are configured in tests, so each explains what is missing. + Assert.All(statuses!, s => + { + Assert.False(s.Configured); + Assert.False(string.IsNullOrWhiteSpace(s.Setup)); + }); + } + + [Fact] + public async Task Refresh_without_a_configured_provider_points_at_the_csv_route() + { + var client = await factory.CreateUserClientAsync("price-none"); + + var response = await client.PostJsonAsync("/api/prices/refresh", new { limit = 1 }); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + var problem = await response.Content.ReadAsStringAsync(); + Assert.Contains("/api/prices/import", problem); + } + + [Fact] + public async Task A_price_guide_sets_tiers_and_the_effective_value_follows_condition() + { + var client = await factory.CreateUserClientAsync("price-import"); + await SeedAsync(client); + + const string csv = """ + title,system,loose,cib,new + Chrono Trigger,SNES,128.00,650.00,12000.00 + Super Metroid,SNES,55.00,210.00,4000.00 + """; + + var result = await (await client.PostAsync("/api/prices/import?source=my-guide", CsvContent(csv))) + .Content.ReadJsonAsync(); + + Assert.Equal(2, result!.Matched); + Assert.Equal(2, result.Updated); + Assert.Equal(0, result.Unmatched); + + var page = await client.GetJsonAsync("/api/games?sort=title"); + var chrono = page!.Items.Single(g => g.Title == "Chrono Trigger"); + var metroid = page.Items.Single(g => g.Title == "Super Metroid"); + + Assert.Equal(650.00m, chrono.MarketValue); // CIB copy takes the CIB tier + Assert.Equal(55.00m, metroid.MarketValue); // loose copy takes the loose tier + Assert.Equal("my-guide", chrono.MarketValueSource); + Assert.NotNull(chrono.MarketValueUpdatedAt); + } + + [Fact] + public async Task Rows_for_games_not_in_the_library_are_reported_not_created() + { + var client = await factory.CreateUserClientAsync("price-unmatched"); + await SeedAsync(client); + + const string csv = """ + title,system,loose + Chrono Trigger,SNES,128.00 + EarthBound,SNES,300.00 + """; + + var result = await (await client.PostAsync("/api/prices/import", CsvContent(csv))) + .Content.ReadJsonAsync(); + + Assert.Equal(1, result!.Matched); + Assert.Equal(1, result.Unmatched); + Assert.Contains("EarthBound", result.UnmatchedTitles[0]); + + // A price guide prices what you own; it does not add to the collection. + var page = await client.GetJsonAsync("/api/games"); + Assert.Equal(2, page!.Total); + } + + [Fact] + public async Task The_same_title_on_two_systems_is_priced_separately() + { + var client = await factory.CreateUserClientAsync("price-platform"); + await client.PostJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "SNES", own = true }); + await client.PostJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "GBA", own = true }); + + const string csv = """ + title,system,loose + Donkey Kong Country,SNES,25.00 + Donkey Kong Country,GBA,18.00 + """; + + await client.PostAsync("/api/prices/import", CsvContent(csv)); + + var page = await client.GetJsonAsync("/api/games"); + Assert.Equal(25.00m, page!.Items.Single(g => g.System == "SNES").MarketValue); + Assert.Equal(18.00m, page.Items.Single(g => g.System == "GBA").MarketValue); + } + + [Fact] + public async Task Dry_run_reports_without_writing() + { + var client = await factory.CreateUserClientAsync("price-dry"); + await SeedAsync(client); + + var result = await (await client.PostAsync( + "/api/prices/import?dryRun=true", + CsvContent("title,system,loose\nChrono Trigger,SNES,128.00"))) + .Content.ReadJsonAsync(); + + Assert.True(result!.DryRun); + Assert.Equal(1, result.Updated); + + var page = await client.GetJsonAsync("/api/games?search=Chrono"); + Assert.Null(page!.Items[0].MarketValue); + } + + [Fact] + public async Task A_guide_never_reaches_another_users_library() + { + var alice = await factory.CreateUserClientAsync("price-alice"); + var bob = await factory.CreateUserClientAsync("price-bob"); + await SeedAsync(alice); + await SeedAsync(bob); + + await bob.PostAsync("/api/prices/import", CsvContent("title,system,loose\nChrono Trigger,SNES,999.00")); + + var alicePage = await alice.GetJsonAsync("/api/games?search=Chrono"); + Assert.Null(alicePage!.Items[0].MarketValue); + } + + [Fact] + public async Task An_unreadable_guide_is_rejected_with_a_reason() + { + var client = await factory.CreateUserClientAsync("price-bad"); + + var response = await client.PostAsync("/api/prices/import", CsvContent("foo,bar\n1,2")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("title", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Pricing_endpoints_require_a_token() + { + var anonymous = factory.CreateClient(); + + Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/prices/status")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, + (await anonymous.PostAsync("/api/prices/import", CsvContent("title,loose\nX,1"))).StatusCode); + } + + private record ProviderStatusPayload(string Name, bool Configured, string Basis, string? Setup); + private record PriceImportPayload( + bool DryRun, int Rows, int Matched, int Updated, int Unmatched, + List UnmatchedTitles, List Problems); + private record GamePayload( + int Id, string Title, string? System, decimal? MarketValue, + DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource, + decimal? ValueLoose, decimal? ValueCib, decimal? ValueNew); + private record PagePayload(List Items, int Total); +} diff --git a/docker-compose.yml b/docker-compose.yml index 9740cff..ab903c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: # Optional market-value lookups. Blank means the pricing endpoints report # 503 and everything else carries on. + PriceCharting__Token: ${PRICECHARTING_TOKEN:-} Ebay__ClientId: ${EBAY_CLIENT_ID:-} Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-} Ebay__UseSandbox: ${EBAY_USE_SANDBOX:-false}