using Ludarium.Application; using Ludarium.Domain; using Ludarium.Infrastructure; using Npgsql; using Testcontainers.PostgreSql; namespace Ludarium.IntegrationTests; public sealed class PostgresStoreTests { [Fact] [Trait("Category", "Container")] public async Task MigrationsPersistenceIdempotencyAndOptimisticConcurrencyWorkAgainstPostgres() { 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_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); await store.InitializeAsync(CancellationToken.None); Assert.Equal(PostgresStore.CurrentSchemaVersion, await store.GetSchemaVersionAsync(CancellationToken.None)); var emptySnapshot = await Assert.ThrowsAsync(() => store.CreateSnapshotAsync("empty baseline", CancellationToken.None)); Assert.Contains("at least one present file with a SHA-256 hash", emptySnapshot.Message); Assert.Empty(await store.ListSnapshotsAsync(CancellationToken.None)); await using (var versions = dataSource.CreateCommand("SELECT array_agg(version ORDER BY version) FROM schema_versions")) { var appliedVersions = Assert.IsType(await versions.ExecuteScalarAsync()); Assert.Collection(appliedVersions, value => Assert.Equal(1, value), value => Assert.Equal(2, value), value => Assert.Equal(3, value), value => Assert.Equal(4, value), value => Assert.Equal(5, value), value => Assert.Equal(6, value), value => Assert.Equal(7, value), value => Assert.Equal(8, value), value => Assert.Equal(9, value), value => Assert.Equal(10, value), value => Assert.Equal(11, value), value => Assert.Equal(12, value), value => Assert.Equal(13, value), value => Assert.Equal(14, value), value => Assert.Equal(15, value), value => Assert.Equal(16, value), value => Assert.Equal(17, value), value => Assert.Equal(18, value), value => Assert.Equal(19, value), value => Assert.Equal(20, value), value => Assert.Equal(21, value), value => Assert.Equal(22, value), value => Assert.Equal(23, value), value => Assert.Equal(24, value), value => Assert.Equal(25, value), value => Assert.Equal(26, value), value => Assert.Equal(27, value)); } var firmware = new FirmwareAsset(Guid.NewGuid(), "psx", "bios", FirmwareAssetKind.Bios, "personal.bin", 512, new string('f', 64), "psx/bios/personal.bin", true, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); await store.SaveFirmwareAssetAsync(firmware, "tester", CancellationToken.None); Assert.Equal(firmware.Id, (await store.GetSelectedFirmwareAssetAsync("psx", "bios", CancellationToken.None))?.Id); Assert.Contains(await store.ListFirmwareAssetsAsync(CancellationToken.None), item => item.Sha256 == firmware.Sha256); var removedFirmware = await store.DeleteFirmwareAssetAsync(firmware.Id, firmware.Version, "tester", CancellationToken.None); Assert.Single(removedFirmware); Assert.Null(await store.GetSelectedFirmwareAssetAsync("psx", "bios", CancellationToken.None)); var library = LibraryRoot.Create("Synthetic", Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-integration-library")), LibraryKind.Mixed) with { IsReadOnly = true, IsAvailable = true }; await store.UpsertLibraryAsync(library, CancellationToken.None); var firstScan = await store.CreateScanAsync(library.Id, ScanMode.Deep, "stable-request", CancellationToken.None); var repeatedScan = await store.CreateScanAsync(library.Id, ScanMode.Deep, "stable-request", CancellationToken.None); Assert.Equal(firstScan.Id, repeatedScan.Id); var content = new ContentBlob(Guid.NewGuid(), 4, new string('a', 64), new string('b', 40), "1234abcd", DateTimeOffset.UtcNow); var artifact = new Artifact(Guid.NewGuid(), library.Id, "roms/nes/Synthetic.nes", 4, DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, content.Id, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "iNES", "nes", Sha256: content.Sha256); var evidence = new Evidence("signature", "iNES", "test", "1", Confidence.Deterministic, DateTimeOffset.UtcNow); await store.UpsertArtifactAsync(artifact, content, [evidence], CancellationToken.None); await store.UpsertArtifactAsync(artifact with { Version = 2 }, content, [evidence], CancellationToken.None); var persisted = await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None); Assert.NotNull(persisted); Assert.Single(persisted.Evidence); Assert.Equal(content.Sha256, persisted.Artifact.Sha256); var integritySnapshot = await store.CreateSnapshotAsync("hashed baseline", CancellationToken.None); Assert.Single(integritySnapshot.Items, item => item.ArtifactId == artifact.Id && item.Sha256 == content.Sha256); Assert.Contains(await store.ListSnapshotsAsync(CancellationToken.None), item => item.Id == integritySnapshot.Id); var dolphinGameId = Guid.NewGuid(); var dolphinArtifact = artifact with { Id = Guid.NewGuid(), RelativePath = "roms/wii/Synthetic Fixture.rvz", Size = 2 * 1024 * 1024, Platform = "wii", Signature = "RVZ", ContentBlobId = null, Sha256 = null, Version = 1 }; await store.UpsertArtifactAsync(dolphinArtifact, null, [new Evidence("extension", "RVZ", "test", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)], CancellationToken.None); // A parameterised command carries exactly one statement: PostgreSQL rejects several in the // extended query protocol, so the fixture rows are inserted separately. await using (var insertGame = dataSource.CreateCommand(""" INSERT INTO games(id,title,version,data) VALUES($1,$2,1,jsonb_build_object('id',$1,'title',$2,'createdAt',now(),'version',1)) """)) { insertGame.Parameters.AddWithValue(dolphinGameId); insertGame.Parameters.AddWithValue("Synthetic Dolphin Fixture"); await insertGame.ExecuteNonQueryAsync(); } 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(dolphinGameId); link.Parameters.AddWithValue(dolphinArtifact.Id); await link.ExecuteNonQueryAsync(); } var dolphinCandidate = Assert.Single(await store.ListDolphinLaunchCandidatesAsync(dolphinGameId, "wii", CancellationToken.None)); Assert.Equal(dolphinArtifact.RelativePath, dolphinCandidate.RelativePath); Assert.True(dolphinCandidate.SourceReadOnly); Assert.Empty(await store.ListDolphinLaunchCandidatesAsync(dolphinGameId, "xbox", CancellationToken.None)); var titledArtifact = artifact with { Id = Guid.NewGuid(), RelativePath = "roms/nes/Super.Game.64 (USA).nes", ContentBlobId = null, Sha256 = null, Version = 1 }; await store.UpsertArtifactAsync(titledArtifact, null, [evidence], CancellationToken.None); var legacyGameId = Guid.NewGuid(); await using (var legacy = dataSource.CreateCommand("INSERT INTO games(id,title,version,data) VALUES($1,$2,1,jsonb_build_object('id',$1,'title',$2,'createdAt',now(),'version',1))")) { legacy.Parameters.AddWithValue(legacyGameId); legacy.Parameters.AddWithValue("0002 - Legacy Racer (USA) zip"); await legacy.ExecuteNonQueryAsync(); } await store.SynchronizeDiscoveredGamesAsync(library.Id, CancellationToken.None); var canonicalGame = Assert.Single((await store.SearchGamesAsync(new GameQuery { Search = "Super Game 64", Platform = "nes", Page = 1, PageSize = 25 }, CancellationToken.None)).Items); Assert.Equal("Super Game 64", canonicalGame.Title); Assert.Equal(GameOrigin.Scan, canonicalGame.Origin); await Assert.ThrowsAsync(() => store.DeleteGameAsync(canonicalGame.Id, canonicalGame.Version, "integration-test", CancellationToken.None)); var browserCandidate = Assert.IsType( await store.GetBrowserPlayCandidateAsync(canonicalGame.Id, CancellationToken.None)); Assert.Equal(titledArtifact.Id, browserCandidate.ArtifactId); Assert.True(browserCandidate.SourceReadOnly); var playSession = new BrowserPlaySession(Guid.NewGuid(), canonicalGame.Id, titledArtifact.Id, PlaySessionState.Ready, "nes", "Ludarium EmulatorJS", "fceumm", "/player.html", new string('d', 64), DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(30)); await store.SaveBrowserPlaySessionAsync(playSession, "integration-test", CancellationToken.None); Assert.Equal(1, await store.CountActiveBrowserPlaySessionsAsync(DateTimeOffset.UtcNow, CancellationToken.None)); Assert.Equal(playSession.Id, (await store.GetBrowserPlaySessionAsync(playSession.Id, CancellationToken.None))?.Id); await store.EndBrowserPlaySessionAsync(playSession.Id, PlaySessionState.Cancelled, "integration-test", "cancelled", "Synthetic cancellation.", CancellationToken.None); Assert.Equal(PlaySessionState.Cancelled, (await store.GetBrowserPlaySessionAsync(playSession.Id, CancellationToken.None))?.State); await using (var playAudit = dataSource.CreateCommand( "SELECT string_agg(data::text,' ') FROM audit_events WHERE data->>'action' LIKE 'BrowserPlay%'")) { var redactedAudit = Assert.IsType(await playAudit.ExecuteScalarAsync()); Assert.DoesNotContain(titledArtifact.RelativePath, redactedAudit, StringComparison.Ordinal); Assert.DoesNotContain(playSession.TokenHash, redactedAudit, StringComparison.Ordinal); } Assert.Equal("Legacy Racer", Assert.Single((await store.SearchGamesAsync(new GameQuery { Search = "Legacy Racer", Page = 1, PageSize = 25 }, CancellationToken.None)).Items).Title); await store.UpsertArtifactAsync(titledArtifact with { Id = Guid.NewGuid(), RelativePath = "roms/nes/Super_Game_64 (USA).nes" }, null, [evidence], CancellationToken.None); await store.SynchronizeDiscoveredGamesAsync(library.Id, CancellationToken.None); Assert.Null(await store.GetBrowserPlayCandidateAsync(canonicalGame.Id, CancellationToken.None)); Assert.Single(await store.ListReleasesAsync(canonicalGame.Id, CancellationToken.None)); var libraryHealth = Assert.Single(await store.ListLibraryHealthAsync(CancellationToken.None), item => item.LibraryId == library.Id); Assert.True(libraryHealth.Artifacts >= 2); // The canonical NES game, the legacy racer and the linked Dolphin fixture all live in this library. Assert.Equal(3, libraryHealth.Games); Assert.Contains("nes", libraryHealth.DetectedPlatforms); var scanRelease = Assert.Single(await store.ListReleasesAsync(canonicalGame.Id, CancellationToken.None)); Assert.Equal(ReleaseOrigin.Scan, scanRelease.Origin); await Assert.ThrowsAsync(() => store.UpdateReleaseAsync(scanRelease.Id, "Unsafe override", "nes", null, null, scanRelease.Version, "integration-test", CancellationToken.None)); await Assert.ThrowsAsync(() => store.DeleteReleaseAsync(scanRelease.Id, scanRelease.Version, "integration-test", CancellationToken.None)); var artwork = titledArtifact with { Id = Guid.NewGuid(), RelativePath = "roms/nes/Super Game 64 (USA)/imgs/covers/cover1.jpg", MediaType = MediaType.SupportFile, State = ArtifactState.Ignored }; await store.UpsertArtifactAsync(artwork, null, [], CancellationToken.None); await store.MarkArtifactIgnoredAsync(artwork.Id, 2, evidence, CancellationToken.None); Assert.Equal(artwork.Id, (await store.GetGameArtworkAsync(canonicalGame.Id, CancellationToken.None))?.Artifact.Id); var game = await store.CreateGameAsync("Synthetic Game", "integration-test", CancellationToken.None); Assert.Equal(GameOrigin.Manual, game.Origin); Assert.Equal(game.Id, (await store.GetGameAsync(game.Id, CancellationToken.None))?.Id); Assert.Null(await store.GetGameAsync(Guid.NewGuid(), CancellationToken.None)); var updated = await store.UpdateGameAsync(game.Id, "Synthetic Game Updated", game.Version, "integration-test", CancellationToken.None); var gamePage = await store.SearchGamesAsync(new GameQuery { Search = "Updated", Page = 1, PageSize = 25 }, CancellationToken.None); Assert.Equal(1, gamePage.Total); Assert.Equal(updated.Id, Assert.Single(gamePage.Items).Id); await Assert.ThrowsAsync(() => store.UpdateGameAsync(game.Id, "Stale Update", game.Version, "integration-test", CancellationToken.None)); var release = await store.CreateReleaseAsync(game.Id, "Synthetic Release", "nes", "world", null, "integration-test", CancellationToken.None); Assert.Contains(await store.ListReleasesAsync(game.Id, CancellationToken.None), item => item.Id == release.Id); Assert.Equal(ReleaseOrigin.Manual, release.Origin); await using (var linkPlayableGame = dataSource.CreateCommand(""" INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at) VALUES($1,$2,'integration-fixture',now()) ON CONFLICT DO NOTHING """)) { linkPlayableGame.Parameters.AddWithValue(game.Id); linkPlayableGame.Parameters.AddWithValue(artifact.Id); await linkPlayableGame.ExecuteNonQueryAsync(); } var unsupportedGame = await store.CreateGameAsync("Synthetic Future Console Game", "integration-test", CancellationToken.None); await store.CreateReleaseAsync(unsupportedGame.Id, "Synthetic Future Console Release", "future-console", null, null, "integration-test", CancellationToken.None); var playability = await store.GetPlayabilitySummaryAsync(CancellationToken.None); Assert.Equal(playability.Supported, playability.Ready + playability.Blocked); Assert.True(playability.Ready >= 1); Assert.True(playability.Unsupported >= 1); Assert.Contains(playability.ByPlatform, item => item.Platform == "nes" && item.Ready >= 1); var updatedRelease = await store.UpdateReleaseAsync(release.Id, "Synthetic Release Revised", "nes", "world", "rev 2", release.Version, "integration-test", CancellationToken.None); Assert.Equal("rev 2", updatedRelease.Revision); await Assert.ThrowsAsync(() => store.UpdateReleaseAsync(release.Id, "Stale", "nes", null, null, release.Version, "integration-test", CancellationToken.None)); var deletedRelease = await store.CreateReleaseAsync(game.Id, "Disposable Release", "nes", null, null, "integration-test", CancellationToken.None); await store.DeleteReleaseAsync(deletedRelease.Id, deletedRelease.Version, "integration-test", CancellationToken.None); Assert.DoesNotContain(await store.ListReleasesAsync(game.Id, CancellationToken.None), item => item.Id == deletedRelease.Id); var collection = await store.CreateCollectionAsync(new("Synthetic favorites", "A deterministic set", CollectionKind.Static, null, true), "integration-test", CancellationToken.None); await store.AddCollectionGameAsync(collection.Id, game.Id, "integration-test", CancellationToken.None); Assert.Equal(game.Id, Assert.Single((await store.SearchCollectionGamesAsync(collection.Id, 1, 25, CancellationToken.None)).Items).Id); var tag = await store.AddGameTagAsync(game.Id, "Co-Op", "integration-test", CancellationToken.None); Assert.Equal("co-op", tag.NormalizedName); Assert.Single(await store.ListTagsAsync(CancellationToken.None), item => item.NormalizedName == "co-op" && item.GameCount == 1); var state = await store.SaveGameUserStateAsync(game.Id, new(true, GamePlayStatus.Playing, 9, 6, 42.5m, "Co-op campaign", 3, DateTimeOffset.UtcNow), 0, "integration-test", CancellationToken.None); Assert.Equal(1, state.Version); var played = await store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None); played = await store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None); Assert.Equal(5, played.PlayCount); Assert.Equal(GamePlayStatus.Playing, played.Status); Assert.NotNull(played.LastPlayedAt); var concurrentPlays = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => store.RecordGamePlayedAsync(game.Id, "integration-test", CancellationToken.None))); Assert.Equal(9, (await store.GetGameUserStateAsync(game.Id, CancellationToken.None)).PlayCount); Assert.Equal(4, concurrentPlays.Select(item => item.PlayCount).Distinct().Count()); var recent = Assert.Single(await store.ListRecentlyPlayedGamesAsync(6, CancellationToken.None)); Assert.Equal(game.Id, recent.Game.Id); Assert.Equal(9, recent.State.PlayCount); await Assert.ThrowsAsync(() => store.SaveGameUserStateAsync(game.Id, new(false, GamePlayStatus.Backlog, null, null, null, null, 0, null), 0, "integration-test", CancellationToken.None)); var smart = await store.CreateCollectionAsync(new("Currently playing", null, CollectionKind.Smart, new CollectionRule(Tags: ["co-op"], Status: GamePlayStatus.Playing, Favorite: true), false), "integration-test", CancellationToken.None); Assert.Contains((await store.SearchCollectionGamesAsync(smart.Id, 1, 25, CancellationToken.None)).Items, item => item.Id == game.Id); var relation = await store.AddGameRelationAsync(game.Id, new(GameRelationKind.Extra, "Soundtrack", null, null), "integration-test", CancellationToken.None); Assert.Contains(await store.ListGameRelationsAsync(game.Id, CancellationToken.None), item => item.Id == relation.Id); var media = await store.AddExternalGameMediaAsync(game.Id, new(GameMediaKind.Manual, "Online manual", "https://example.test/manual.pdf", "Synthetic", "manual-1"), "integration-test", CancellationToken.None); Assert.True(media.Selected); var achievementProgress = new GameAchievementProgress(game.Id, "Synthetic", "game-1", 1, 1, DateTimeOffset.UtcNow, [new(Guid.NewGuid(), "achievement-1", "First step", null, 5, true, DateTimeOffset.UtcNow, null)]); await store.UpsertAchievementProgressAsync(achievementProgress, CancellationToken.None); Assert.Equal(1, (await store.GetAchievementProgressAsync(game.Id, CancellationToken.None))?.Earned); var dataEntryId = Guid.NewGuid(); var firstDataRevision = new GameDataRevision(Guid.NewGuid(), dataEntryId, 0, "slot-1.sav", "application/octet-stream", 16, new string('a', 64), $"{game.Id:N}/{dataEntryId:N}/{Guid.NewGuid():N}.bin", "ManualUpload", "integration-test", DateTimeOffset.UtcNow); firstDataRevision = firstDataRevision with { Location = $"{game.Id:N}/{dataEntryId:N}/{firstDataRevision.Id:N}.bin" }; var dataInput = new GameDataEntryInput(GameDataKind.Save, "Main campaign", "mGBA", "Steam Deck", "Before the final dungeon"); var concurrentDataWrites = await Task.WhenAll( store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput, 0, firstDataRevision, "integration-test", CancellationToken.None), store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput, 0, firstDataRevision, "integration-test", CancellationToken.None)); var dataItem = concurrentDataWrites[0]; Assert.Equal(1, dataItem.Entry.Version); Assert.Equal(dataItem.Entry.Id, concurrentDataWrites[1].Entry.Id); var secondDataRevision = firstDataRevision with { Id = Guid.NewGuid(), Length = 24, Sha256 = new string('b', 64), CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1) }; secondDataRevision = secondDataRevision with { Location = $"{game.Id:N}/{dataEntryId:N}/{secondDataRevision.Id:N}.bin" }; dataItem = await store.SaveGameDataRevisionAsync(game.Id, dataEntryId, new(GameDataKind.Save, "Main campaign", "mGBA", "Steam Deck", "After the final dungeon"), dataItem.Entry.Version, secondDataRevision, "integration-test", CancellationToken.None); Assert.Equal(2, dataItem.Entry.RevisionCount); var reboundDataRevision = secondDataRevision with { Id = Guid.NewGuid(), SourceId = "artifact-version-2", CreatedAt = DateTimeOffset.UtcNow.AddSeconds(2) }; reboundDataRevision = reboundDataRevision with { Location = $"{game.Id:N}/{dataEntryId:N}/{reboundDataRevision.Id:N}.bin" }; dataItem = await store.SaveGameDataRevisionAsync(game.Id, dataEntryId, dataInput, dataItem.Entry.Version, reboundDataRevision, "integration-test", CancellationToken.None); Assert.Equal(3, dataItem.Entry.RevisionCount); Assert.Equal("artifact-version-2", dataItem.CurrentRevision.SourceId); Assert.Equal(3, (await store.ListGameDataRevisionsAsync(dataEntryId, 1, 25, CancellationToken.None)).Total); var revisionLocations = await store.ListGameDataRevisionLocationsAsync(CancellationToken.None); Assert.Contains(firstDataRevision.Location, revisionLocations); Assert.Contains(secondDataRevision.Location, revisionLocations); dataItem = await store.UpdateGameDataAsync(game.Id, dataEntryId, new("Main campaign", "mGBA", "Living room handheld", "Verified post-game save"), dataItem.Entry.Version, "integration-test", CancellationToken.None); Assert.Equal("Living room handheld", dataItem.Entry.Device); await Assert.ThrowsAsync(() => store.UpdateGameDataAsync(game.Id, dataEntryId, new("Stale", null, null, null), 1, "integration-test", CancellationToken.None)); var dataSummary = await store.GetGameDataSummaryAsync(game.Id, CancellationToken.None); Assert.Equal(1, dataSummary.Saves); Assert.Equal(3, dataSummary.Revisions); Assert.Equal(64, dataSummary.Bytes); var customPlatform = await store.CreatePlatformDefinitionAsync(new("synthetic-console", "Synthetic Console", "Synthetic", ["synth"], true), "integration-test", CancellationToken.None); var disabledPlatform = await store.UpdatePlatformDefinitionAsync(customPlatform.Id, new(customPlatform.Id, customPlatform.Name, customPlatform.Category, customPlatform.Aliases, false), customPlatform.Version, "integration-test", CancellationToken.None); Assert.False(disabledPlatform.Enabled); Assert.Contains((await store.SearchGamesAsync(new GameQuery { Platform = "nes", Page = 1, PageSize = 25 }, CancellationToken.None)).Items, item => item.Id == game.Id); Assert.Empty((await store.SearchGamesAsync(new GameQuery { Platform = "ps5", Page = 1, PageSize = 25 }, CancellationToken.None)).Items); var input = new WishlistInput(game.Id, game.Title, "nes", WishlistPriority.High, "Find a boxed copy", WishlistStatus.WaitingForSale, 25, 20, "EUR", "Synthetic Store", "https://example.test/game", "Boxed", "EU"); var wish = await store.CreateWishlistItemAsync(input, "integration-test", CancellationToken.None); Assert.Equal(wish.Id, Assert.Single((await store.SearchWishlistAsync("boxed", "nes", WishlistPriority.High, WishlistStatus.WaitingForSale, "price", 1, 25, CancellationToken.None)).Items).Id); Assert.Equal(1, (await store.GetWishlistSummaryAsync(CancellationToken.None)).PriceDrops); await Assert.ThrowsAsync(() => store.CreateWishlistItemAsync(input, "integration-test", CancellationToken.None)); var updatedInput = input with { Priority = WishlistPriority.Low, Notes = "Wait for a sale", CurrentPrice = 30 }; var updatedWish = await store.UpdateWishlistItemAsync(wish.Id, updatedInput, wish.Version, "integration-test", CancellationToken.None); Assert.Equal(WishlistPriority.Low, updatedWish.Priority); await Assert.ThrowsAsync(() => store.UpdateWishlistItemAsync(wish.Id, input, wish.Version, "integration-test", CancellationToken.None)); await store.DeleteWishlistItemAsync(updatedWish.Id, updatedWish.Version, "integration-test", CancellationToken.None); var futureWish = await store.CreateWishlistItemAsync(new(null, "Unique Future Game", "ps5", WishlistPriority.Normal, null), "integration-test", CancellationToken.None); var uniqueFutureGame = await store.CreateGameAsync("Unique Future Game", "integration-test", CancellationToken.None); Assert.Equal(1, await store.ReconcileWishlistAsync("integration-test", CancellationToken.None)); var reconciled = Assert.Single((await store.SearchWishlistAsync("Unique Future Game", null, null, WishlistStatus.Acquired, null, 1, 25, CancellationToken.None)).Items); Assert.Equal(uniqueFutureGame.Id, reconciled.GameId); await store.DeleteWishlistItemAsync(reconciled.Id, reconciled.Version, "integration-test", CancellationToken.None); var ambiguousWish = await store.CreateWishlistItemAsync(new(null, "Ambiguous Future Game", null, WishlistPriority.Normal, null), "integration-test", CancellationToken.None); await store.CreateGameAsync("Ambiguous Future Game", "integration-test", CancellationToken.None); await store.CreateGameAsync("Ambiguous Future Game", "integration-test", CancellationToken.None); Assert.Equal(0, await store.ReconcileWishlistAsync("integration-test", CancellationToken.None)); var stillUnlinked = Assert.Single((await store.SearchWishlistAsync("Ambiguous Future Game", null, null, WishlistStatus.Interested, null, 1, 25, CancellationToken.None)).Items); Assert.Null(stillUnlinked.GameId); await store.DeleteWishlistItemAsync(stillUnlinked.Id, stillUnlinked.Version, "integration-test", CancellationToken.None); Assert.Empty((await store.SearchWishlistAsync(null, null, null, null, null, 1, 25, CancellationToken.None)).Items); var visiblePage = await store.SearchGamesAsync(new GameQuery { Page = 1, PageSize = 200 }, CancellationToken.None); var dashboard = await store.GetDashboardSummaryAsync(CancellationToken.None); Assert.Equal(visiblePage.Total, dashboard.Games); var dormant = LibraryRoot.Create("Dormant", Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-dormant-library")), LibraryKind.Mixed) with { Enabled = false, IsReadOnly = false, IsAvailable = false, HasEntries = false }; await store.UpsertLibraryAsync(dormant, CancellationToken.None); Assert.Equal(dashboard.RootsRequiringAttention, (await store.GetDashboardSummaryAsync(CancellationToken.None)).RootsRequiringAttention); var nesPlatform = Assert.Single(await store.ListPlatformsAsync(CancellationToken.None), item => item.Platform == "nes"); Assert.Equal((await store.SearchGamesAsync(new GameQuery { Platform = "nes", Page = 1, PageSize = 200 }, CancellationToken.None)).Total, nesPlatform.Games); var hiddenSupportGame = await store.CreateGameAsync("Steam", "integration-test", CancellationToken.None); Assert.Null(await store.GetGameAsync(hiddenSupportGame.Id, CancellationToken.None)); var supportAwareDashboard = await store.GetDashboardSummaryAsync(CancellationToken.None); Assert.Equal(visiblePage.Total, supportAwareDashboard.Games); Assert.Equal(1, supportAwareDashboard.ExcludedSupportGames); var defaults = await store.GetOperationalSettingsAsync(CancellationToken.None); var settings = await store.SaveOperationalSettingsAsync(defaults with { DefaultPageSize = 100 }, defaults.Version, CancellationToken.None); Assert.Equal(100, settings.DefaultPageSize); await Assert.ThrowsAsync(() => store.SaveOperationalSettingsAsync(defaults, defaults.Version, CancellationToken.None)); var artworkRecord = new GameArtwork(Guid.NewGuid(), game.Id, ArtworkRole.VerifiedBoxFront, ArtworkVerificationStatus.AutomaticallyVerified, "Synthetic", "fixture", "art-1", "exact-test", Confidence.Deterministic, 600, 900, 0.6667m, 1234, new string('c', 64), 100m, DateTimeOffset.UtcNow); await store.UpsertGameArtworkAsync(artworkRecord, CancellationToken.None); Assert.Equal(artworkRecord.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id); var alternateArtwork = artworkRecord with { Id = Guid.NewGuid(), Source = "Alternate", QualityScore = 82m, Selected = false }; await store.UpsertGameArtworkAsync(alternateArtwork, CancellationToken.None); Assert.Equal(2, (await store.ListGameArtworkAsync(game.Id, CancellationToken.None)).Count); await store.SelectGameArtworkAsync(game.Id, alternateArtwork.Id, CancellationToken.None); Assert.Equal(alternateArtwork.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id); await store.SelectGameArtworkAsync(game.Id, artworkRecord.Id, CancellationToken.None); Assert.Equal(artworkRecord.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None))?.Id); Assert.Single(await store.ListGameArtworkAsync(game.Id, CancellationToken.None), item => item.Selected); var ps5Artwork = artworkRecord with { Id = Guid.NewGuid(), Platform = "ps5" }; var windowsArtwork = artworkRecord with { Id = Guid.NewGuid(), Platform = "windows" }; await store.UpsertGameArtworkAsync(ps5Artwork, CancellationToken.None); await store.UpsertGameArtworkAsync(windowsArtwork, CancellationToken.None); Assert.Equal(ps5Artwork.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None, "ps5"))?.Id); Assert.Equal(windowsArtwork.Id, (await store.GetSelectedGameArtworkAsync(game.Id, CancellationToken.None, "windows"))?.Id); Assert.Equal(3, (await store.ListGameArtworkAsync(game.Id, CancellationToken.None)).Count(item => item.Selected)); await Assert.ThrowsAsync(() => store.SelectGameArtworkAsync(game.Id, Guid.NewGuid(), CancellationToken.None)); Assert.DoesNotContain(await store.ListArtworkNeedingReviewAsync(CancellationToken.None), item => item.Id == artworkRecord.Id); var disposableGame = await store.CreateGameAsync("Disposable Manual Game", "integration-test", CancellationToken.None); await Assert.ThrowsAsync(() => store.DeleteGameAsync(disposableGame.Id, disposableGame.Version - 1, "integration-test", CancellationToken.None)); await store.CreateReleaseAsync(disposableGame.Id, "Disposable Version", "nes", null, null, "integration-test", CancellationToken.None); await store.AddGameTagAsync(disposableGame.Id, "Disposable", "integration-test", CancellationToken.None); var retainedWish = await store.CreateWishlistItemAsync(new(disposableGame.Id, disposableGame.Title, "nes", WishlistPriority.Normal, "Retain this intent"), "integration-test", CancellationToken.None); var deletion = await store.DeleteGameAsync(disposableGame.Id, disposableGame.Version, "integration-test", CancellationToken.None); Assert.Equal(disposableGame.Id, deletion.Game.Id); Assert.DoesNotContain(await store.ListGamesAsync(CancellationToken.None), item => item.Id == disposableGame.Id); Assert.Empty(await store.ListReleasesAsync(disposableGame.Id, CancellationToken.None)); var standaloneWish = Assert.Single((await store.SearchWishlistAsync("Disposable Manual Game", null, null, null, null, 1, 25, CancellationToken.None)).Items); Assert.Equal(retainedWish.Id, standaloneWish.Id); Assert.Null(standaloneWish.GameId); Assert.Equal(retainedWish.Version + 1, standaloneWish.Version); await store.DeleteWishlistItemAsync(standaloneWish.Id, standaloneWish.Version, "integration-test", CancellationToken.None); var queuedJob = await store.EnqueueJobAsync("Synthetic", "{}", "synthetic-job", CancellationToken.None); Assert.Equal(queuedJob.Id, (await store.EnqueueJobAsync("Synthetic", "{}", "synthetic-job", CancellationToken.None)).Id); var leasedJob = Assert.IsType(await store.LeaseJobAsync("integration-worker", TimeSpan.FromMinutes(1), CancellationToken.None)); Assert.Equal(JobState.Leased, leasedJob.State); await store.UpdateJobAsync(leasedJob with { State = JobState.Completed, LeaseOwner = null, LeaseUntil = null }, CancellationToken.None); Assert.Contains(await store.ListJobsAsync(CancellationToken.None), item => item.Id == queuedJob.Id && item.State == JobState.Completed); var openReviewsBeforeLifecycle = (await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews; var review = new ReviewItem(Guid.NewGuid(), "Synthetic ambiguity", Severity.Notice, ReviewState.Open, "{}", DateTimeOffset.UtcNow); await store.AddReviewAsync(review, CancellationToken.None); Assert.Equal(openReviewsBeforeLifecycle + 1, (await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews); var reviewGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None), group => group.ReviewIds.Contains(review.Id)); var filteredReviewGroups = await store.SearchReviewGroupsAsync(new ReviewGroupQuery { State = ReviewState.Open, Search = "Synthetic ambiguity", Severity = Severity.Notice, Order = ReviewGroupOrder.Newest, Page = 1, PageSize = 1 }, CancellationToken.None); Assert.Contains(filteredReviewGroups.Items, group => group.ReviewIds.Contains(review.Id)); Assert.True(filteredReviewGroups.Total >= 1); Assert.Equal(0, (await store.SearchReviewGroupsAsync(new ReviewGroupQuery { State = ReviewState.Open, Search = "no-such-review-group", Page = 1, PageSize = 50 }, CancellationToken.None)).Total); var operation = await store.ResolveReviewGroupAsync(reviewGroup.ReviewIds, (int)reviewGroup.Count, "accepted exact test group", "integration-test", CancellationToken.None); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved); await store.UndoReviewOperationAsync(operation.Id, "integration-test", CancellationToken.None); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open); var deferredOperation = await store.DeferReviewGroupAsync(reviewGroup.ReviewIds, (int)reviewGroup.Count, "deferred exact test group", "integration-test", CancellationToken.None); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Deferred); Assert.Equal(openReviewsBeforeLifecycle, (await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews); var deferredGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Deferred, CancellationToken.None), group => group.ReviewIds.Contains(review.Id)); var reopenedOperation = await store.ReopenReviewGroupAsync(deferredGroup.ReviewIds, (int)deferredGroup.Count, ReviewState.Deferred, "reopened exact test group", "integration-test", CancellationToken.None); var reopenedReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id); Assert.Equal(ReviewState.Open, reopenedReview.State); Assert.Null(reopenedReview.Resolution); Assert.Equal(openReviewsBeforeLifecycle + 1, (await store.GetDashboardSummaryAsync(CancellationToken.None)).OpenReviews); var resolvedOperation = await store.ResolveReviewGroupAsync(reopenedOperation.Before.Select(item => item.Id).ToArray(), reopenedOperation.Before.Count, "resolved exact test group", "integration-test", CancellationToken.None); var resolvedGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Resolved, CancellationToken.None), group => group.ReviewIds.Contains(review.Id)); Assert.Equal("resolved exact test group", resolvedGroup.Resolution); Assert.NotNull(resolvedGroup.ResolvedAt); var reopenedResolvedOperation = await store.ReopenReviewGroupAsync(resolvedGroup.ReviewIds, (int)resolvedGroup.Count, ReviewState.Resolved, "reopened resolved test group", "integration-test", CancellationToken.None); var reopenedResolvedReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id); Assert.Equal(ReviewState.Open, reopenedResolvedReview.State); Assert.Null(reopenedResolvedReview.Resolution); Assert.Null(reopenedResolvedReview.ResolvedAt); await store.UndoReviewOperationAsync(reopenedResolvedOperation.Id, "integration-test", CancellationToken.None); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved); await store.UndoReviewOperationAsync(resolvedOperation.Id, "integration-test", CancellationToken.None); await store.UndoReviewOperationAsync(reopenedOperation.Id, "integration-test", CancellationToken.None); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Deferred); await store.UndoReviewOperationAsync(deferredOperation.Id, "integration-test", CancellationToken.None); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open); var artifactReview = new ReviewItem(Guid.NewGuid(), "Unknown content", Severity.Notice, ReviewState.Open, System.Text.Json.JsonSerializer.Serialize(new { artifact.Id, artifact.RelativePath }), DateTimeOffset.UtcNow); await store.AddReviewAsync(artifactReview, CancellationToken.None); var artifactGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None), group => group.ReviewIds.Contains(artifactReview.Id)); var supportOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count, ReviewBulkAction.ClassifySupportContent, null, null, "integration-test", CancellationToken.None); var classifiedSupport = Assert.IsType(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None)); Assert.Equal(ArtifactState.Ignored, classifiedSupport.Artifact.State); Assert.Equal(MediaType.SupportFile, classifiedSupport.Artifact.MediaType); Assert.Contains(classifiedSupport.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "support-content"); await store.UndoReviewOperationAsync(supportOperation.Id, "integration-test", CancellationToken.None); var restoredSupportArtifact = Assert.IsType(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None)); Assert.Equal(ArtifactState.Present, restoredSupportArtifact.Artifact.State); Assert.DoesNotContain(restoredSupportArtifact.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "support-content"); var platformOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count, ReviewBulkAction.AcceptPlatform, "ps5", null, "integration-test", CancellationToken.None); Assert.Equal("ps5", (await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None))!.Artifact.Platform); await store.UndoReviewOperationAsync(platformOperation.Id, "integration-test", CancellationToken.None); var restoredPlatformArtifact = Assert.IsType(await store.GetArtifactDetailsAsync(artifact.Id, CancellationToken.None)); Assert.Equal("nes", restoredPlatformArtifact.Artifact.Platform); Assert.DoesNotContain(restoredPlatformArtifact.Evidence, item => item.Kind == "review.bulk-action" && item.Value == "platform:ps5"); var exclusionOperation = await store.ApplyReviewGroupActionAsync(artifactGroup.ReviewIds, (int)artifactGroup.Count, ReviewBulkAction.ExcludeFolderPattern, null, artifactGroup.PathPattern, "integration-test", CancellationToken.None); Assert.Contains("roms/nes/**", (await store.GetLibraryAsync(library.Id, CancellationToken.None))!.Exclusions!); await store.UndoReviewOperationAsync(exclusionOperation.Id, "integration-test", CancellationToken.None); Assert.Empty((await store.GetLibraryAsync(library.Id, CancellationToken.None))!.Exclusions ?? []); var resolution = await store.ResolveReviewAsync(review.Id, "accepted", "integration-test", CancellationToken.None); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Resolved); await store.UndoResolutionAsync(resolution.Id, "integration-test", CancellationToken.None); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == review.Id && item.State == ReviewState.Open); var staleUndoReview = new ReviewItem(Guid.NewGuid(), "Synthetic stale undo", Severity.Notice, ReviewState.Open, "{}", DateTimeOffset.UtcNow); await store.AddReviewAsync(staleUndoReview, CancellationToken.None); var staleUndoGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None), group => group.ReviewIds.Contains(staleUndoReview.Id)); var staleUndoOperation = await store.DeferReviewGroupAsync(staleUndoGroup.ReviewIds, (int)staleUndoGroup.Count, "deferred before a later action", "integration-test", CancellationToken.None); await store.SetReviewStateAsync(staleUndoReview.Id, ReviewState.Open, "integration-test", CancellationToken.None); await Assert.ThrowsAsync(() => store.UndoReviewOperationAsync(staleUndoOperation.Id, "integration-test", CancellationToken.None)); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), item => item.Id == staleUndoReview.Id && item.State == ReviewState.Open); var mixedReason = $"Mixed resolution {Guid.NewGuid():N}"; await store.AddReviewAsync(new ReviewItem(Guid.NewGuid(), mixedReason, Severity.Notice, ReviewState.Resolved, "{}", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "one recorded outcome"), CancellationToken.None); await store.AddReviewAsync(new ReviewItem(Guid.NewGuid(), mixedReason, Severity.Notice, ReviewState.Resolved, "{}", DateTimeOffset.UtcNow), CancellationToken.None); var mixedGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Resolved, CancellationToken.None), group => group.Reason == mixedReason); Assert.Null(mixedGroup.Resolution); Assert.Null(mixedGroup.ResolvedAt); var catalogSource = new CatalogSource(Guid.NewGuid(), "Synthetic CRC catalog", "logiqx", "1", null, "Ready", DateTimeOffset.UtcNow, 1); var catalogEntry = new CatalogEntry(Guid.NewGuid(), catalogSource.Id, "Synthetic", artifact.RelativePath, artifact.Size, null, null, content.Crc32, "synthetic-crc"); await store.UpsertCatalogSourceAsync(catalogSource, CancellationToken.None); await store.UpsertCatalogEntriesAsync([catalogEntry], CancellationToken.None); Assert.Equal(1, await store.MatchCatalogAsync(catalogSource.Id, CancellationToken.None)); var catalogReviewGroup = Assert.Single(await store.ListReviewGroupsAsync(ReviewState.Open, CancellationToken.None), group => group.Reason == "Catalog ambiguity" && group.ReviewIds.Count == 1); await store.DeferReviewGroupAsync(catalogReviewGroup.ReviewIds, (int)catalogReviewGroup.Count, "deferred catalog ambiguity", "integration-test", CancellationToken.None); Assert.Equal(1, await store.MatchCatalogAsync(catalogSource.Id, CancellationToken.None)); var deferredCatalogReview = Assert.Single(await store.ListReviewsAsync(CancellationToken.None), item => item.Reason == "Catalog ambiguity" && item.State == ReviewState.Deferred); Assert.Equal("deferred catalog ambiguity", deferredCatalogReview.Resolution); await using var restartedDataSource = NpgsqlDataSource.Create(postgres.GetConnectionString()); var restartedStore = new PostgresStore(restartedDataSource); await restartedStore.InitializeAsync(CancellationToken.None); Assert.Contains(await restartedStore.ListLibrariesAsync(CancellationToken.None), item => item.Id == library.Id); Assert.Contains(await restartedStore.ListGamesAsync(CancellationToken.None), item => item.Id == updated.Id && item.Title == updated.Title); Assert.Equal(2, (await restartedStore.ListClaimsAsync("Game", game.Id, CancellationToken.None)).Count); Assert.Equal(100, (await restartedStore.GetOperationalSettingsAsync(CancellationToken.None)).DefaultPageSize); Assert.True((await restartedStore.GetGameUserStateAsync(game.Id, CancellationToken.None)).Favorite); Assert.Contains(await restartedStore.ListCollectionsAsync(CancellationToken.None), item => item.Id == collection.Id && item.GameCount == 1); Assert.Contains(await restartedStore.ListPlatformDefinitionsAsync(CancellationToken.None), item => item.Id == customPlatform.Id && !item.Enabled); var restartedDataItem = Assert.Single(await restartedStore.ListGameDataAsync(game.Id, CancellationToken.None)); Assert.Equal(dataItem.Entry.Version, restartedDataItem.Entry.Version); var deletedDataRevisions = await restartedStore.DeleteGameDataAsync(game.Id, dataEntryId, restartedDataItem.Entry.Version, "integration-test", CancellationToken.None); Assert.Equal(3, deletedDataRevisions.Count); Assert.Empty(await restartedStore.ListGameDataAsync(game.Id, CancellationToken.None)); await using (var scale = dataSource.CreateCommand(""" INSERT INTO artifacts(id,library_id,relative_path,size,data,updated_at) SELECT gen_random_uuid(), $1, CASE WHEN value % 1000 = 0 THEN 'roms/nes/needle-' ELSE 'roms/nes/synthetic-' END || lpad(value::text, 6, '0') || '.nes', 32768, '{}'::jsonb, now() FROM generate_series(1,250000) AS value """)) { scale.CommandTimeout = 120; scale.Parameters.AddWithValue(library.Id); Assert.Equal(250_000, await scale.ExecuteNonQueryAsync()); } await using (var analyze = dataSource.CreateCommand("EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM artifacts WHERE relative_path ILIKE '%needle%' ORDER BY relative_path LIMIT 50")) await using (var reader = await ExecuteScaleQueryAsync(analyze)) { var plan = new List(); while (await reader.ReadAsync()) plan.Add(reader.GetString(0)); Assert.Contains(plan, line => line.Contains("ix_artifacts_path_trgm", StringComparison.Ordinal)); Assert.Contains(plan, line => line.Contains("Execution Time", StringComparison.Ordinal)); } } private static Task ExecuteScaleQueryAsync(NpgsqlCommand command) { command.CommandTimeout = 120; return command.ExecuteReaderAsync(); } }