using System.Net; using LudosData.Api.Contracts; namespace LudosData.Api.Tests; public class StatsTests(LudosApiFactory factory) : IClassFixture { private static object Game( string title, string system = "SNES", string genre = "rpg", string? year = "1995", bool own = true, bool dumped = false, bool played = false, bool finished = false, int? rating = null, decimal? marketValue = null, decimal? purchasePrice = null, string condition = "Unspecified") => new { title, system, genre, year, own, dumped, played, finished, rating, marketValue, purchasePrice, condition, }; private static async Task SeedAsync(HttpClient client) { // 4 owned, 3 played, 1 finished — so every funnel stage differs. await client.PostJsonAsync("/api/games", Game("Alpha", "SNES", "rpg", "1995", played: true, finished: true, rating: 9, marketValue: 100m, purchasePrice: 40m)); await client.PostJsonAsync("/api/games", Game("Beta", "SNES", "rpg", "1996", played: true, rating: 7, marketValue: 50m)); await client.PostJsonAsync("/api/games", Game("Gamma", "N64", "fps", "2001", played: true, dumped: true)); await client.PostJsonAsync("/api/games", Game("Delta", "N64", "racing", "2011")); } [Fact] public async Task The_funnel_reports_each_stage_as_a_subset_of_the_last() { var client = await factory.CreateUserClientAsync("stats-funnel"); await SeedAsync(client); var stats = await client.GetJsonAsync("/api/stats"); Assert.Equal(4, stats!.TotalGames); Assert.Equal(4, stats.Funnel.Owned); Assert.Equal(3, stats.Funnel.Played); Assert.Equal(1, stats.Funnel.Finished); // The two numbers a backlog view exists to surface. Assert.Equal(1, stats.Backlog); // owned, never played Assert.Equal(2, stats.InProgress); // played, not finished Assert.Equal(1, stats.Dumped); } [Fact] public async Task Breakdowns_are_ordered_by_count_then_alphabetically() { var client = await factory.CreateUserClientAsync("stats-breakdown"); await SeedAsync(client); var stats = await client.GetJsonAsync("/api/stats"); Assert.Equal(["N64", "SNES"], stats!.BySystem.Select(s => s.Label)); Assert.Equal(2, stats.BySystem[0].Count); // A tie must not shuffle between requests. var again = await client.GetJsonAsync("/api/stats"); Assert.Equal(stats.BySystem.Select(s => s.Label), again!.BySystem.Select(s => s.Label)); } [Fact] public async Task Decades_are_derived_from_a_free_text_year_column() { var client = await factory.CreateUserClientAsync("stats-decades"); await SeedAsync(client); // Year is free text, so these are the shapes that actually turn up. await client.PostJsonAsync("/api/games", Game("Vague", year: "circa 1998")); await client.PostJsonAsync("/api/games", Game("Empty", year: "")); await client.PostJsonAsync("/api/games", Game("Junk", year: "unknown")); var stats = await client.GetJsonAsync("/api/stats"); var decades = stats!.ByDecade.ToDictionary(d => d.Label, d => d.Count); Assert.Equal(3, decades["1990s"]); // 1995, 1996, "circa 1998" Assert.Equal(1, decades["2000s"]); // 2001 Assert.Equal(1, decades["2010s"]); // 2011 // "" and "unknown" are omitted rather than bucketed as a zero decade, // so the totals fall short of the library count by design. Assert.Equal(5, decades.Values.Sum()); } [Fact] public async Task Decades_are_in_chronological_order() { var client = await factory.CreateUserClientAsync("stats-decade-order"); await SeedAsync(client); var stats = await client.GetJsonAsync("/api/stats"); // Time reads left to right, regardless of which decade is largest. var labels = stats!.ByDecade.Select(d => d.Label).ToList(); Assert.Equal(labels.OrderBy(l => l, StringComparer.Ordinal), labels); } [Fact] public async Task Value_carries_coverage_age_and_source_alongside_the_total() { var client = await factory.CreateUserClientAsync("stats-value"); await SeedAsync(client); var stats = await client.GetJsonAsync("/api/stats"); Assert.Equal(150m, stats!.Value.Total); Assert.Equal(2, stats.Value.PricedCount); // The two unpriced games are reported, so a total is never mistaken for // covering the whole collection. Assert.Equal(2, stats.Value.UnpricedCount); Assert.NotNull(stats.Value.OldestValuedAt); Assert.Contains("manual", stats.Value.Sources); } [Fact] public async Task An_empty_library_reports_zeroes_rather_than_failing() { var client = await factory.CreateUserClientAsync("stats-empty"); var stats = await client.GetJsonAsync("/api/stats"); Assert.Equal(0, stats!.TotalGames); Assert.Equal(0, stats.Funnel.Owned); Assert.Empty(stats.BySystem); Assert.Equal(0m, stats.Value.Total); // No games rated means no average, which is not the same as zero. Assert.Null(stats.AverageRating); Assert.Null(stats.Value.OldestValuedAt); } [Fact] public async Task Ratings_average_only_over_rated_games() { var client = await factory.CreateUserClientAsync("stats-rating"); await SeedAsync(client); var stats = await client.GetJsonAsync("/api/stats"); Assert.Equal(2, stats!.RatedCount); // (9 + 7) / 2 — the two unrated games do not count as zero. Assert.Equal(8.0, stats.AverageRating); } [Fact] public async Task Stats_cover_only_the_signed_in_users_library() { var alice = await factory.CreateUserClientAsync("stats-alice"); var bob = await factory.CreateUserClientAsync("stats-bob"); await SeedAsync(alice); var bobStats = await bob.GetJsonAsync("/api/stats"); Assert.Equal(0, bobStats!.TotalGames); } [Fact] public async Task Stats_require_a_token() { var response = await factory.CreateClient().GetAsync("/api/stats"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } }