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
@@ -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);
}