Files
LudosData/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs
T
ckochandClaude Opus 5 d34e6b4ced Make PriceCharting runs auditable and pin matches by product id
Two changes aimed at the first real run, since the integration cannot be
exercised here without a subscription.

Refresh now reports which product each game matched — name, console and the
source's id — next to the prices, and dry-run surfaces it before anything is
written. This is the failure that would otherwise go unnoticed: a lookup for
the DS "Chrono Trigger" resolving to the SNES original returns entirely
plausible numbers for the wrong game, and nothing in a bare price would say
so.

The matched id is then stored on the game, and later refreshes look it up
directly instead of repeating the title search. Cheaper, and stable — a
search that drifts to a different edition next month cannot silently
re-price something that was already matched correctly.

IPriceProvider takes an optional sourceId so this stays provider-agnostic.
eBay ignores it, having no stable per-product identifier in Browse.

139 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:52:59 -04:00

427 lines
15 KiB
C#

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);
}
public class PriceChartingMatchTests
{
[Fact]
public void The_matched_product_and_console_are_reported()
{
using var document = JsonDocument.Parse("""
{
"status": "success",
"id": "6910",
"product-name": "Chrono Trigger",
"console-name": "Super Nintendo",
"loose-price": 12800, "cib-price": 65000, "new-price": 1200000
}
""");
var estimate = PriceChartingProvider.Parse(document);
// Without this a dry run cannot tell a DS entry that resolved to the
// SNES original from one that resolved correctly.
Assert.Equal("Chrono Trigger", estimate.MatchedName);
Assert.Equal("Super Nintendo", estimate.MatchedConsole);
Assert.Equal("6910", estimate.SourceId);
}
[Fact]
public void A_numeric_id_is_read_as_a_string()
{
using var document = JsonDocument.Parse("""
{ "status": "success", "id": 6910, "loose-price": 100 }
""");
Assert.Equal("6910", PriceChartingProvider.Parse(document).SourceId);
}
[Fact]
public void Missing_match_metadata_is_not_fatal()
{
using var document = JsonDocument.Parse("""{ "loose-price": 12800 }""");
var estimate = PriceChartingProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose);
Assert.Null(estimate.MatchedName);
Assert.Null(estimate.SourceId);
}
[Fact]
public void A_stored_id_is_preferred_over_a_title_search()
{
// Documents the intent of the lookup switch: with an id, the query is an
// exact product fetch, so a drifting title search cannot re-price a
// different edition on a later run.
Assert.Equal("super nintendo Chrono Trigger",
PriceChartingProvider.BuildQuery("Chrono Trigger", "SNES"));
}
}