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>
427 lines
15 KiB
C#
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"));
|
|
}
|
|
}
|