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