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