using Ludarium.Application; using Ludarium.Domain; using Ludarium.Infrastructure; using Npgsql; using Testcontainers.PostgreSql; namespace Ludarium.IntegrationTests; /// /// The library browser's query, executed by the real database. /// /// /// The library used to sort the forty-eight games already on screen, which reordered a page instead /// of the collection, and it could not filter on the favourites, status, ratings or tags it lets an /// operator set. Both are properties of the query, so both are proven here against PostgreSQL. /// public sealed class GameLibraryQueryTests { [Fact] [Trait("Category", "Container")] public async Task LibraryFiltersAndOrderingAreAnsweredByTheDatabase() { if (!string.Equals(Environment.GetEnvironmentVariable("LUDARIUM_RUN_CONTAINER_TESTS"), "1", StringComparison.Ordinal)) return; await using var postgres = new PostgreSqlBuilder("postgres:16.15-bookworm") .WithDatabase("ludarium_query_test") .WithUsername("ludarium") .WithPassword("synthetic-test-password") .Build(); await postgres.StartAsync(); await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString()); var store = new PostgresStore(dataSource); await store.InitializeAsync(CancellationToken.None); var library = LibraryRoot.Create("Query fixture", Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-query-library")), LibraryKind.Rom) with { IsReadOnly = true, IsAvailable = true }; await store.UpsertLibraryAsync(library, CancellationToken.None); var alpha = await store.CreateGameAsync("Alpha Adventure", "integration-test", CancellationToken.None); var bravo = await store.CreateGameAsync("Bravo Blast", "integration-test", CancellationToken.None); var charlie = await store.CreateGameAsync("Charlie Chase", "integration-test", CancellationToken.None); await store.SaveGameUserStateAsync(alpha.Id, new(true, GamePlayStatus.Playing, 5, null, 40m, null, 3, DateTimeOffset.UtcNow.AddDays(-1)), 0, "integration-test", CancellationToken.None); await store.SaveGameUserStateAsync(bravo.Id, new(false, GamePlayStatus.Completed, 3, null, 100m, null, 9, DateTimeOffset.UtcNow), 0, "integration-test", CancellationToken.None); await store.AddGameTagAsync(charlie.Id, "Co-op", "integration-test", CancellationToken.None); // --- filters --------------------------------------------------------------------------- var favourites = await store.SearchGamesAsync(new GameQuery { Favorite = true }, CancellationToken.None); Assert.Equal("Alpha Adventure", Assert.Single(favourites.Items).Title); // The total describes the filtered population, not the whole catalog. Assert.Equal(1, favourites.Total); Assert.Equal(3, (await store.SearchGamesAsync(new GameQuery(), CancellationToken.None)).Total); Assert.Equal("Alpha Adventure", Assert.Single((await store.SearchGamesAsync( new GameQuery { Status = GamePlayStatus.Playing }, CancellationToken.None)).Items).Title); Assert.Equal("Bravo Blast", Assert.Single((await store.SearchGamesAsync( new GameQuery { Status = GamePlayStatus.Completed }, CancellationToken.None)).Items).Title); Assert.Equal("Alpha Adventure", Assert.Single((await store.SearchGamesAsync( new GameQuery { MinimumRating = 4 }, CancellationToken.None)).Items).Title); Assert.Equal(2, (await store.SearchGamesAsync(new GameQuery { MinimumRating = 3 }, CancellationToken.None)).Total); Assert.Equal("Charlie Chase", Assert.Single((await store.SearchGamesAsync( new GameQuery { Tag = "Co-op" }, CancellationToken.None)).Items).Title); var collection = await store.CreateCollectionAsync(new("Weekend picks", null, CollectionKind.Static, null, false), "integration-test", CancellationToken.None); await store.AddCollectionGameAsync(collection.Id, bravo.Id, "integration-test", CancellationToken.None); Assert.Equal("Bravo Blast", Assert.Single((await store.SearchGamesAsync( new GameQuery { CollectionId = collection.Id }, CancellationToken.None)).Items).Title); // --- ordering -------------------------------------------------------------------------- var byRating = await store.SearchGamesAsync(new GameQuery { Sort = "rating" }, CancellationToken.None); Assert.Equal(["Alpha Adventure", "Bravo Blast", "Charlie Chase"], byRating.Items.Select(item => item.Title)); var byPlayed = await store.SearchGamesAsync(new GameQuery { Sort = "played" }, CancellationToken.None); Assert.Equal(["Bravo Blast", "Alpha Adventure", "Charlie Chase"], byPlayed.Items.Select(item => item.Title)); // A page boundary cannot change the answer, which page-local sorting could not guarantee. var firstPage = await store.SearchGamesAsync(new GameQuery { Sort = "title", PageSize = 1 }, CancellationToken.None); var secondPage = await store.SearchGamesAsync(new GameQuery { Sort = "title", PageSize = 1, Page = 2 }, CancellationToken.None); Assert.Equal("Alpha Adventure", Assert.Single(firstPage.Items).Title); Assert.Equal("Bravo Blast", Assert.Single(secondPage.Items).Title); Assert.Equal(3, firstPage.Total); // --- playable copies ------------------------------------------------------------------- Assert.Empty((await store.SearchGamesAsync(new GameQuery { PlayableOnly = true }, CancellationToken.None)).Items); await LinkArtifactAsync(store, dataSource, library.Id, charlie.Id, "roms/snes/Charlie Chase.sfc", "snes"); await LinkArtifactAsync(store, dataSource, library.Id, bravo.Id, "roms/snes/Bravo Blast.txt", "snes"); // Only the allowlisted platform-and-container pair counts as a playable copy. Assert.Equal("Charlie Chase", Assert.Single((await store.SearchGamesAsync( new GameQuery { PlayableOnly = true }, CancellationToken.None)).Items).Title); // --- scoped releases ------------------------------------------------------------------- await store.CreateReleaseAsync(alpha.Id, "Alpha Adventure", "snes", null, null, "integration-test", CancellationToken.None); await store.CreateReleaseAsync(bravo.Id, "Bravo Blast", "snes", null, null, "integration-test", CancellationToken.None); Assert.Equal("Alpha Adventure", Assert.Single( await store.ListReleasesForGamesAsync([alpha.Id], CancellationToken.None)).Title); Assert.Empty(await store.ListReleasesForGamesAsync([], CancellationToken.None)); Assert.Equal(2, (await store.ListReleasesAsync(null, CancellationToken.None)).Count); } private static async Task LinkArtifactAsync(PostgresStore store, NpgsqlDataSource dataSource, Guid libraryId, Guid gameId, string relativePath, string platform) { var artifact = new Artifact(Guid.NewGuid(), libraryId, relativePath, 4 * 1024 * 1024, DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, platform); await store.UpsertArtifactAsync(artifact, null, [], CancellationToken.None); await using var link = dataSource.CreateCommand(""" INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at) VALUES($1,$2,'integration-fixture',now()) """); link.Parameters.AddWithValue(gameId); link.Parameters.AddWithValue(artifact.Id); await link.ExecuteNonQueryAsync(); } }