Add CSV price guides and PriceCharting alongside eBay

An eBay production keyset needs account verification, which leaves pricing
blocked on someone else's review queue. These are two routes that are not.

CSV price guide, POST /api/prices/import: no account, works immediately.
Column names are matched by alias, so a PriceCharting bulk export
(product-name / console-name / loose-price) and a hand-kept
title,system,loose,cib,new sheet both parse, along with currency symbols,
thousands separators and blank cells. Rows match on title + system, so the
same game on two consoles is priced separately, and rows for games not in
the library are reported rather than silently added.

PriceCharting adapter: paid, but access is immediate with no review, and it
quotes the same three tiers this app stores, so no inference is needed.
Their API docs are not reachable without an account, so the parser follows
the widely-used convention — integer pennies under hyphenated keys — and is
tolerant enough that a naming difference degrades to "no price" instead of
throwing. One method to adjust if it differs.

Providers are now a registry rather than a single service. /api/prices/status
lists each one with what it is configured for, what its numbers actually
mean, and how to enable it; refresh takes an optional provider name and
falls back to the first configured one. With none configured it answers 503
pointing at the CSV route.

Checked against the real library: a five-row guide in PriceCharting's own
column names priced four games and reported the fifth as not owned, with
each effective value following that copy's condition. Demo figures were
cleared afterwards.

135 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:46:10 -04:00
co-authored by Claude Opus 5
parent ca70bcef34
commit 8e136f42f8
8 changed files with 889 additions and 66 deletions
@@ -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
{
/// <summary>Which source to price from. Defaults to the first configured one.</summary>
public string? Provider { get; init; }
/// <summary>Limit the run to specific games. Empty means the whole library.</summary>
public List<int>? 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<PriceRefreshItem> 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<string> UnmatchedTitles, IReadOnlyList<string> Problems);
/// <summary>
/// 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 <see cref="EbayPriceProvider"/> — 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.
/// </summary>
[ApiController]
[Route("api/prices")]
[Authorize]
public class PricesController(
LudosDbContext db,
IPriceProvider provider,
IEnumerable<IPriceProvider> providers,
ILogger<PricesController> logger) : ControllerBase
{
[HttpGet("status")]
public IActionResult Status() => Ok(new
private static readonly Dictionary<string, (string Basis, string Setup)> 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<IEnumerable<ProviderStatus>> 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<ActionResult<PriceRefreshResult>> 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));
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("import")]
[RequestSizeLimit(32 * 1024 * 1024)]
public async Task<ActionResult<PriceImportResult>> 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<string>();
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()}";
}
+13 -1
View File
@@ -117,8 +117,20 @@ builder.Services.AddSingleton<IImageStorage, ImageStorage>();
// 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<EbayOptions>(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<PriceChartingOptions>(
builder.Configuration.GetSection(PriceChartingOptions.SectionName));
builder.Services.Configure<EbayOptions>(
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<IPriceProvider, PriceChartingProvider>();
builder.Services.AddSingleton<IPriceProvider, EbayPriceProvider>();
builder.Services
@@ -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";
/// <summary>
/// Subscription token, from the Subscriptions page of a PriceCharting
/// account (the "API/Download" button).
/// </summary>
public string Token { get; set; } = string.Empty;
public bool IsConfigured => !string.IsNullOrWhiteSpace(Token);
}
/// <summary>
/// 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. <see cref="Parse"/> is deliberately tolerant and is the only
/// place to adjust if their field names differ from this.
/// </summary>
public class PriceChartingProvider(
IHttpClientFactory httpClientFactory,
IOptions<PriceChartingOptions> options,
ILogger<PriceChartingProvider> logger) : IPriceProvider
{
private readonly PriceChartingOptions _options = options.Value;
public string Name => "pricecharting";
public bool IsConfigured => _options.IsConfigured;
/// <summary>
/// PriceCharting keys its catalogue by console name, so the query carries
/// one. Their names are spelled out rather than abbreviated.
/// </summary>
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<PriceEstimate> 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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Prices arrive as integer pennies; 1250 means $12.50.</summary>
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;
}
}
@@ -0,0 +1,136 @@
using System.Globalization;
using LudosData.Api.Services;
namespace LudosData.Api.Services.Pricing;
/// <summary>One row of an external price list.</summary>
public record PriceGuideRow(
string Title,
string? System,
decimal? Loose,
decimal? Cib,
decimal? New);
public record PriceGuideParseResult(
IReadOnlyList<PriceGuideRow> Rows,
IReadOnlyList<string> Problems);
/// <summary>
/// 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.
/// </summary>
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<string>();
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<PriceGuideRow>();
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);
}
/// <summary>
/// Tolerant of what a spreadsheet emits: currency symbols, thousands
/// separators, and integer pennies where a guide quotes them that way.
/// </summary>
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;
}
}
@@ -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<LudosApiFactory>
{
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<List<ProviderStatusPayload>>("/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<PriceImportPayload>();
Assert.Equal(2, result!.Matched);
Assert.Equal(2, result.Updated);
Assert.Equal(0, result.Unmatched);
var page = await client.GetJsonAsync<PagePayload>("/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<PriceImportPayload>();
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<PagePayload>("/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<PagePayload>("/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<PriceImportPayload>();
Assert.True(result!.DryRun);
Assert.Equal(1, result.Updated);
var page = await client.GetJsonAsync<PagePayload>("/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<PagePayload>("/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<string> UnmatchedTitles, List<string> 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<GamePayload> Items, int Total);
}