Files
LudosData/backend/tests/LudosData.Api.Tests/PricingTests.cs
T
ckochandClaude Opus 5 ca70bcef34 Add market value: tiered prices and an eBay Browse provider
Researched the options first. PriceCharting is the standard for retro prices
but requires a paid subscription for both its API and its bulk download.
eBay's sold-price data sits behind the Marketplace Insights API, which is a
limited release closed to new developers. The free game-price APIs cover
current digital storefronts, not physical retro copies. So there is no free
route to sold prices, and this uses eBay Browse — active listings, which are
asking prices, labelled as such rather than presented as valuations.

Schema now holds three prices per game (loose, CIB, new), with marketValue
as whichever tier matches that copy's condition. Changing a condition
re-prices from the stored tiers with no further lookup, and the dashboard
can later show both actual value and what a collection would be worth
complete.

The judgement lives in classification and aggregation, both pure and both
tested without credentials:

  * listings are sorted into tiers from their titles, and accessories,
    reproductions and multi-game lots are discarded — a "box only" listing
    at $45 counted as a copy would halve the loose estimate for a $130 cart
  * the discard qualifier is required. The first version matched a bare
    "box", which threw out "complete in box" and "with box and manual",
    i.e. most of the CIB tier, while keeping exactly the listings the
    filter existed to remove. A test asserting on tiers rather than counts
    caught it.
  * median with an interquartile trim, since one optimist asking 50x moves
    a mean and not a median
  * sample counts travel with the estimate, because a tier drawn from two
    listings warrants less confidence than one drawn from thirty

Credentials are optional: with none set, /api/prices/status reports
configured=false and refresh answers 503 with instructions, while the rest
of the app is unaffected.

101 backend tests.

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

239 lines
8.8 KiB
C#

using System.Text.Json;
using LudosData.Api.Domain;
using LudosData.Api.Services.Pricing;
namespace LudosData.Api.Tests;
/// <summary>
/// The pricing logic, tested without touching eBay.
///
/// Everything that decides what a number means — which listings count, which
/// tier they land in, and how they aggregate — is pure and lives here. Only the
/// HTTP call itself needs credentials, and it is the least interesting part.
/// </summary>
public class ListingClassificationTests
{
[Theory]
[InlineData("Chrono Trigger SNES Cartridge Only", GameCondition.Loose)]
[InlineData("Super Metroid - loose cart, tested", GameCondition.Loose)]
[InlineData("Banjo-Kazooie N64 game only", GameCondition.Loose)]
[InlineData("Earthbound SNES CIB", GameCondition.Cib)]
[InlineData("Ocarina of Time complete in box", GameCondition.Cib)]
[InlineData("Mario Kart 64 with box and manual", GameCondition.Cib)]
[InlineData("Boxed Pokemon Yellow Game Boy", GameCondition.Cib)]
[InlineData("Metroid Prime FACTORY SEALED", GameCondition.Sealed)]
[InlineData("Halo 3 Xbox 360 Brand New Sealed", GameCondition.Sealed)]
[InlineData("Final Fantasy VII WATA 9.4 graded", GameCondition.Sealed)]
public void Titles_are_sorted_into_the_right_tier(string title, GameCondition expected)
{
Assert.Equal(expected, ListingCondition.Classify(title, "Used"));
}
[Theory]
// These are the dangerous ones: cheap, plentiful, and not the game.
[InlineData("Chrono Trigger SNES BOX ONLY no game")]
[InlineData("Super Mario World manual only")]
[InlineData("Zelda Ocarina of Time REPRODUCTION cartridge")]
[InlineData("N64 game case replacement")]
[InlineData("Custom art label for Earthbound")]
[InlineData("Lot of 12 SNES games")]
[InlineData("Nintendo 64 bundle 5 games")]
public void Accessories_reproductions_and_lots_are_discarded(string title)
{
// Counting a $6 "box only" listing as a copy of the game would drag a
// loose median to nonsense.
Assert.Null(ListingCondition.Classify(title, "Used"));
}
[Fact]
public void An_unqualified_listing_falls_back_to_the_sellers_flag()
{
Assert.Equal(GameCondition.Sealed, ListingCondition.Classify("Chrono Trigger", "New"));
// Unqualified and used reads as loose, the conservative assumption.
Assert.Equal(GameCondition.Loose, ListingCondition.Classify("Chrono Trigger", "Used"));
}
}
public class PriceMathTests
{
[Fact]
public void Median_of_an_odd_sample_is_the_middle_value()
{
// Sorted first: [10, 20, 30].
Assert.Equal(20m, PriceMath.Median([10m, 30m, 20m]));
}
[Fact]
public void Median_of_an_even_sample_averages_the_middle_pair()
{
Assert.Equal(25m, PriceMath.Median([10m, 20m, 30m, 40m]));
}
[Fact]
public void Median_of_nothing_is_null_rather_than_zero()
{
// A game with no listings is unpriced, which is not the same as free.
Assert.Null(PriceMath.Median([]));
}
[Fact]
public void A_wildly_optimistic_listing_does_not_move_the_estimate()
{
var withOutlier = new List<decimal> { 40m, 42m, 45m, 44m, 43m, 41m, 5000m };
var kept = PriceMath.RemoveOutliers(withOutlier);
Assert.DoesNotContain(5000m, kept);
// A mean would have been dragged past 750; the median holds.
Assert.InRange(PriceMath.Median(kept)!.Value, 40m, 45m);
}
[Fact]
public void Small_samples_are_left_alone()
{
// With three points, quartiles are meaningless and trimming would throw
// away most of the evidence.
var values = new List<decimal> { 10m, 20m, 900m };
Assert.Equal(3, PriceMath.RemoveOutliers(values).Count);
}
[Fact]
public void Summarise_reports_a_price_and_a_sample_count_per_tier()
{
var listings = new List<PricedListing>
{
new(20m, GameCondition.Loose),
new(24m, GameCondition.Loose),
new(22m, GameCondition.Loose),
new(80m, GameCondition.Cib),
new(90m, GameCondition.Cib),
new(400m, GameCondition.Sealed),
};
var estimate = PriceMath.Summarise(listings, discarded: 4);
Assert.Equal(22m, estimate.Loose);
Assert.Equal(85m, estimate.Cib);
Assert.Equal(400m, estimate.New);
Assert.Equal(3, estimate.LooseSamples);
Assert.Equal(2, estimate.CibSamples);
Assert.Equal(1, estimate.NewSamples);
Assert.Equal(4, estimate.Discarded);
Assert.True(estimate.HasAnyPrice);
}
[Fact]
public void A_tier_with_no_listings_stays_null()
{
var estimate = PriceMath.Summarise([new PricedListing(20m, GameCondition.Loose)], 0);
Assert.Equal(20m, estimate.Loose);
Assert.Null(estimate.Cib);
Assert.Null(estimate.New);
}
}
public class EbayResponseParsingTests
{
/// <summary>Shaped like a real Browse item_summary/search response.</summary>
private const string SampleResponse = """
{
"total": 8,
"itemSummaries": [
{ "title": "Chrono Trigger SNES Cartridge Only Authentic",
"condition": "Used", "price": { "value": "120.00", "currency": "USD" } },
{ "title": "Chrono Trigger Super Nintendo loose cart tested",
"condition": "Used", "price": { "value": "135.50", "currency": "USD" } },
{ "title": "Chrono Trigger SNES game only",
"condition": "Used", "price": { "value": "128.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES CIB complete in box",
"condition": "Used", "price": { "value": "650.00", "currency": "USD" } },
{ "title": "Chrono Trigger Super Nintendo with box and manual",
"condition": "Used", "price": { "value": "700.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES FACTORY SEALED WATA",
"condition": "New", "price": { "value": "12000.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES BOX ONLY no game",
"condition": "Used", "price": { "value": "45.00", "currency": "USD" } },
{ "title": "Lot of 6 SNES RPG games including Chrono Trigger",
"condition": "Used", "price": { "value": "300.00", "currency": "USD" } }
]
}
""";
[Fact]
public void A_search_response_is_split_into_tiers()
{
using var document = JsonDocument.Parse(SampleResponse);
var estimate = EbayPriceProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose); // median of 120, 128, 135.50
Assert.Equal(675.00m, estimate.Cib); // mean of the middle pair
Assert.Equal(12000.00m, estimate.New);
// The box-only listing and the multi-game lot are both thrown out. Left
// in, the $45 box would have halved the loose estimate.
Assert.Equal(2, estimate.Discarded);
Assert.Equal(3, estimate.LooseSamples);
}
[Fact]
public void A_response_with_no_results_yields_no_prices()
{
using var document = JsonDocument.Parse("""{ "total": 0, "itemSummaries": [] }""");
var estimate = EbayPriceProvider.Parse(document);
Assert.False(estimate.HasAnyPrice);
}
[Fact]
public void A_response_missing_the_results_array_does_not_throw()
{
using var document = JsonDocument.Parse("""{ "total": 0, "warnings": [] }""");
Assert.False(EbayPriceProvider.Parse(document).HasAnyPrice);
}
[Fact]
public void Listings_without_a_usable_price_are_discarded()
{
using var document = JsonDocument.Parse("""
{
"itemSummaries": [
{ "title": "Chrono Trigger SNES loose", "condition": "Used" },
{ "title": "Chrono Trigger SNES loose", "condition": "Used",
"price": { "value": "0.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES loose", "condition": "Used",
"price": { "value": "130.00", "currency": "USD" } }
]
}
""");
var estimate = EbayPriceProvider.Parse(document);
Assert.Equal(130.00m, estimate.Loose);
Assert.Equal(1, estimate.LooseSamples);
Assert.Equal(2, estimate.Discarded);
}
[Theory]
[InlineData("SNES", "Super Nintendo SNES")]
[InlineData("N64", "Nintendo 64")]
[InlineData("360", "Xbox 360")]
[InlineData("PS1", "PlayStation 1 PS1")]
public void The_console_name_is_added_to_the_search(string system, string expected)
{
// Without it, "Chrono Trigger" returns SNES, PS1 and DS copies together
// and the median lands between three different markets.
Assert.Equal($"Chrono Trigger {expected}",
EbayPriceProvider.BuildQuery("Chrono Trigger", system));
}
[Fact]
public void A_game_with_no_system_searches_on_title_alone()
{
Assert.Equal("Chrono Trigger", EbayPriceProvider.BuildQuery("Chrono Trigger", null));
}
}