using System.Text.Json;
using LudosData.Api.Domain;
using LudosData.Api.Services.Pricing;
namespace LudosData.Api.Tests;
///
/// 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.
///
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 { 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 { 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
{
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
{
/// Shaped like a real Browse item_summary/search response.
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));
}
}