Files
Ludarium release export df869819ce
Public source validation / source (push) Successful in 2m16s
Publish Ludarium source
2026-09-03 02:08:58 +02:00

620 lines
49 KiB
C#

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<SnapshotPreconditionException>(() =>
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<int[]>(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<ResourceConflictException>(() => store.DeleteGameAsync(canonicalGame.Id,
canonicalGame.Version, "integration-test", CancellationToken.None));
var browserCandidate = Assert.IsType<BrowserPlayCandidate>(
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<string>(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<ResourceConflictException>(() => store.UpdateReleaseAsync(scanRelease.Id,
"Unsafe override", "nes", null, null, scanRelease.Version, "integration-test", CancellationToken.None));
await Assert.ThrowsAsync<ResourceConflictException>(() => 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<ConcurrencyException>(() =>
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<ConcurrencyException>(() => 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<ConcurrencyException>(() => 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<ConcurrencyException>(() => 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<ResourceConflictException>(() => 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<ConcurrencyException>(() => 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<ConcurrencyException>(() => 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<KeyNotFoundException>(() => 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<ConcurrencyException>(() => 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<BackgroundJob>(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<ArtifactDetails>(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<ArtifactDetails>(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<ArtifactDetails>(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<ConcurrencyException>(() => 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<string>();
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<NpgsqlDataReader> ExecuteScaleQueryAsync(NpgsqlCommand command)
{
command.CommandTimeout = 120;
return command.ExecuteReaderAsync();
}
}