using System.Security.Cryptography; using System.IO.Compression; using Ludarium.Application; using Ludarium.Domain; using Ludarium.Infrastructure; namespace Ludarium.IntegrationTests; public sealed class SafeScanTests { [Fact] public async Task CueWithExactEcmSidecarIsReviewableButNotReportedMissing() { var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); try { await File.WriteAllTextAsync(Path.Combine(path, "disc.cue"), "FILE \"disc.bin\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n"); await File.WriteAllBytesAsync(Path.Combine(path, "disc.bin.ecm"), "ECM\0synthetic-fixture"u8.ToArray()); var before = Manifest(path); var store = new MemoryStore(); var root = LibraryRoot.Create("PSX", path, LibraryKind.DiscImage); await store.UpsertLibraryAsync(root, CancellationToken.None); var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem()); var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "ecm-sidecar", CancellationToken.None); await coordinator.ExecuteAsync(scan.Id, CancellationToken.None); var bundle = Assert.Single(await store.ListBundlesAsync(CancellationToken.None)); Assert.Equal(BundleState.CompleteWithWarnings, bundle.State); Assert.Contains(bundle.Members, member => member.Role == "encoded track"); Assert.DoesNotContain(bundle.Findings, finding => finding.Contains("not found", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(await store.ListReviewsAsync(CancellationToken.None), item => item.Reason == "Incomplete bundle"); Assert.Equal(before, Manifest(path)); } finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } } [Fact] public async Task DormantLibraryCannotBeScanned() { var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); try { await File.WriteAllBytesAsync(Path.Combine(path, "game.nes"), [0x4e, 0x45, 0x53, 0x1a]); var store = new MemoryStore(); var root = LibraryRoot.Create("Dormant", path, LibraryKind.Rom) with { Enabled = false }; await store.UpsertLibraryAsync(root, CancellationToken.None); var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem()); var scan = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "dormant", CancellationToken.None); await coordinator.ExecuteAsync(scan.Id, CancellationToken.None); var completed = await store.GetScanAsync(scan.Id, CancellationToken.None); Assert.Equal(ScanState.Failed, completed?.State); Assert.Contains("dormant", completed?.Error, StringComparison.OrdinalIgnoreCase); Assert.Empty((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items); } finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } } [Fact] public async Task DeepScanPreservesSourceAndBuildsReviewableWindowsBundle() { var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); try { await File.WriteAllBytesAsync(Path.Combine(path, "setup.exe"), "MZ-invalid-static-fixture"u8.ToArray()); await File.WriteAllBytesAsync(Path.Combine(path, "setup-1.bin"), "payload-one"u8.ToArray()); await File.WriteAllBytesAsync(Path.Combine(path, "setup-3.bin"), "payload-three"u8.ToArray()); await File.WriteAllBytesAsync(Path.Combine(path, "sample.nes"), [0x4e, 0x45, 0x53, 0x1a, 0, 0, 0, 0]); Directory.CreateDirectory(Path.Combine(path, "roms", "Tools", "Switch")); await File.WriteAllTextAsync(Path.Combine(path, "roms", "Tools", "Switch", "prod.keys"), "synthetic-key-list"); var before = Manifest(path); var store = new MemoryStore(); var root = LibraryRoot.Create("Synthetic", path, LibraryKind.Mixed, hashPolicy: HashPolicy.CatalogCompatible); await store.UpsertLibraryAsync(root, CancellationToken.None); var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem()); var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "safe-scan", CancellationToken.None); await coordinator.ExecuteAsync(scan.Id, CancellationToken.None); Assert.Equal(before, Manifest(path)); Assert.Equal(ScanState.Completed, (await store.GetScanAsync(scan.Id, CancellationToken.None))?.State); var artifacts = await store.SearchArtifactsAsync(null, 1, 50, CancellationToken.None); Assert.Equal(5, artifacts.Total); Assert.Contains(artifacts.Items, item => item.State == ArtifactState.Ignored && item.MediaType == MediaType.SupportFile); var bundle = Assert.Single(await store.ListBundlesAsync(CancellationToken.None)); Assert.Equal(BundleKind.WindowsInstaller, bundle.Kind); Assert.Equal(BundleState.Incomplete, bundle.State); Assert.Contains(bundle.Findings, x => x.Contains("payload 2", StringComparison.Ordinal)); Assert.Contains(await store.ListReviewsAsync(CancellationToken.None), x => x.Reason == "Incomplete bundle"); Assert.Equal(["ArtworkEnrichment", "MetadataEnrichment"], (await store.ListJobsAsync(CancellationToken.None)).Select(job => job.Kind).Order().ToArray()); } finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } } [Fact] public async Task VerifiedRenameRetainsArtifactIdentityAndUnavailableRootDoesNotRemoveIt() { var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); try { await File.WriteAllBytesAsync(Path.Combine(path, "before.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]); var store = new MemoryStore(); var root = LibraryRoot.Create("Moves", path, LibraryKind.Rom); await store.UpsertLibraryAsync(root, CancellationToken.None); var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem()); var first = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "first", CancellationToken.None); await coordinator.ExecuteAsync(first.Id, CancellationToken.None); var original = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items); File.Move(Path.Combine(path, "before.nes"), Path.Combine(path, "after.nes")); var second = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "second", CancellationToken.None); await coordinator.ExecuteAsync(second.Id, CancellationToken.None); var moved = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items); Assert.Equal(original.Id, moved.Id); Assert.Equal("after.nes", moved.RelativePath); Assert.Equal(ArtifactState.Present, moved.State); Assert.Equal(1, (await store.GetScanAsync(second.Id, CancellationToken.None))?.MovedItems); Directory.Move(path, path + "-offline"); var failed = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "offline", CancellationToken.None); await coordinator.ExecuteAsync(failed.Id, CancellationToken.None); Assert.Equal(ArtifactState.Present, Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items).State); Directory.Move(path + "-offline", path); } finally { if (Directory.Exists(path)) Directory.Delete(path, true); if (Directory.Exists(path + "-offline")) Directory.Delete(path + "-offline", true); } } [Fact] public async Task EmptyMountedSourceDoesNotEraseAnExistingCatalog() { var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); try { await File.WriteAllBytesAsync(Path.Combine(path, "preserved.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]); var store = new MemoryStore(); var root = LibraryRoot.Create("Protected", path, LibraryKind.Rom); await store.UpsertLibraryAsync(root, CancellationToken.None); var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem()); var initial = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "initial", CancellationToken.None); await coordinator.ExecuteAsync(initial.Id, CancellationToken.None); File.Delete(Path.Combine(path, "preserved.nes")); var empty = await coordinator.RequestAsync(root.Id, ScanMode.Quick, "empty", CancellationToken.None); await coordinator.ExecuteAsync(empty.Id, CancellationToken.None); var result = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items); Assert.Equal(ArtifactState.Present, result.State); Assert.Equal(ScanState.Failed, (await store.GetScanAsync(empty.Id, CancellationToken.None))?.State); Assert.Contains("source is empty", (await store.GetScanAsync(empty.Id, CancellationToken.None))?.Error, StringComparison.OrdinalIgnoreCase); Assert.False((await store.GetLibraryAsync(root.Id, CancellationToken.None))?.HasEntries); } finally { if (Directory.Exists(path)) Directory.Delete(path, true); } } [Fact] public async Task IntegrityScanHashesOnDemandLibrariesWithoutMutatingTheSource() { var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); try { await File.WriteAllBytesAsync(Path.Combine(path, "identity.nes"), [0x4e, 0x45, 0x53, 0x1a, 1, 2, 3, 4]); var before = Manifest(path); var store = new MemoryStore(); var root = LibraryRoot.Create("On demand", path, LibraryKind.Rom, hashPolicy: HashPolicy.OnDemand); await store.UpsertLibraryAsync(root, CancellationToken.None); var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem()); var scan = await coordinator.RequestAsync(root.Id, ScanMode.Integrity, "integrity", CancellationToken.None); await coordinator.ExecuteAsync(scan.Id, CancellationToken.None); var artifact = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items); Assert.NotNull(artifact.Sha256); Assert.Equal(before, Manifest(path)); } finally { if (Directory.Exists(path)) Directory.Delete(path, true); } } [Fact] public async Task DeepScanRecordsZipMembersWithoutChangingTheArchive() { var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); try { var archivePath = Path.Combine(path, "collection.zip"); await using (var output = File.Create(archivePath)) using (var archive = new ZipArchive(output, ZipArchiveMode.Create)) { var entry = archive.CreateEntry("game/demo.nes"); await using var member = entry.Open(); await member.WriteAsync(new byte[] { 0x4e, 0x45, 0x53, 0x1a }); } var before = Manifest(path); var store = new MemoryStore(); var root = LibraryRoot.Create("Archives", path, LibraryKind.Mixed, inspectArchives: true); await store.UpsertLibraryAsync(root, CancellationToken.None); var coordinator = new ScanCoordinator(store, new ReadOnlyLibraryFileSystem()); var scan = await coordinator.RequestAsync(root.Id, ScanMode.Deep, "zip", CancellationToken.None); await coordinator.ExecuteAsync(scan.Id, CancellationToken.None); var artifact = Assert.Single((await store.SearchArtifactsAsync(null, 1, 10, CancellationToken.None)).Items); var memberRecord = Assert.Single(await store.ListArchiveMembersAsync(artifact.Id, CancellationToken.None)); Assert.Equal("game/demo.nes", memberRecord.Path); Assert.Equal(before, Manifest(path)); } finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } } [Fact] public async Task InventoryExportsAreVersionedEscapedAndCancellationSafe() { var path = Path.Combine(Path.GetTempPath(), "ludarium-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); try { var store = new MemoryStore(); var artifact = new Artifact(Guid.NewGuid(), Guid.NewGuid(), "quoted,\nname.nes", 4, DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, Guid.NewGuid(), DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "iNES", "nes", Sha256: new string('a', 64)); await store.UpsertArtifactAsync(artifact, null, [], CancellationToken.None); var service = new InventoryExportService(store, path); foreach (var format in new[] { "json", "csv", "sha256" }) { var result = await service.CreateAsync(format, CancellationToken.None); var content = await File.ReadAllTextAsync(Path.Combine(path, result.FileName)); Assert.Contains(ReleaseIdentity.Version, content, StringComparison.Ordinal); Assert.Contains(format == "json" ? "schemaVersion" : "ludarium-schema-version", content, StringComparison.Ordinal); if (format == "sha256") Assert.Contains("quoted,\\nname.nes", content, StringComparison.Ordinal); Assert.Equal(1, result.Count); } var cancelledRoot = Path.Combine(path, "cancelled"); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => new InventoryExportService(store, cancelledRoot).CreateAsync("json", cancellation.Token)); Assert.Empty(Directory.GetFiles(cancelledRoot)); } finally { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } } private static string Manifest(string root) => string.Join('|', Directory.GetFiles(root).Order().Select(x => Path.GetFileName(x) + ":" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))))); private sealed class MemoryStore : ILudariumStore { private readonly Dictionary libraries = []; private readonly Dictionary scans = []; private readonly Dictionary artifacts = []; private readonly Dictionary bundles = []; private readonly Dictionary reviews = []; private readonly Dictionary> archiveMembers = []; private readonly List findings = []; private readonly List jobs = []; private OperationalSettings settings = OperationalSettings.Defaults; public Task InitializeAsync(CancellationToken c) => Task.CompletedTask; public Task GetSchemaVersionAsync(CancellationToken c) => Task.FromResult(PostgresStore.CurrentSchemaVersion); public Task> ListLibrariesAsync(CancellationToken c) => Task.FromResult>(libraries.Values.ToArray()); public Task GetLibraryAsync(Guid id, CancellationToken c) => Task.FromResult(libraries.GetValueOrDefault(id)); public Task> ListLibraryHealthAsync(CancellationToken c) => Task.FromResult>([]); public Task UpsertLibraryAsync(LibraryRoot r, CancellationToken c) { libraries[r.Id] = r; return Task.CompletedTask; } public Task CreateScanAsync(Guid libraryId, ScanMode mode, string? key, CancellationToken c) { var existing = scans.Values.FirstOrDefault(x => x.Checkpoint == "key:" + key); if (existing is not null) return Task.FromResult(existing); var s = new ScanRun(Guid.NewGuid(), libraryId, mode, ScanState.Queued, "Queued", 0, null, 0, null, "key:" + key, false, DateTimeOffset.UtcNow); scans[s.Id] = s; return Task.FromResult(s); } public Task> ListScansAsync(CancellationToken c) => Task.FromResult>(scans.Values.ToArray()); public Task GetScanAsync(Guid id, CancellationToken c) => Task.FromResult(scans.GetValueOrDefault(id)); public Task UpdateScanAsync(ScanRun s, CancellationToken c) { scans[s.Id] = s; return Task.CompletedTask; } public Task UpsertArtifactAsync(Artifact a, ContentBlob? b, IReadOnlyList e, CancellationToken c) { artifacts[a.Id] = a; return Task.CompletedTask; } public Task MarkArtifactIgnoredAsync(Guid id, long version, Evidence evidence, CancellationToken c) { artifacts[id] = artifacts[id] with { State = ArtifactState.Ignored, MediaType = MediaType.SupportFile, Platform = null, Version = version }; return Task.CompletedTask; } public Task GetArtifactOverrideAsync(Guid artifactId, CancellationToken c) => Task.FromResult(null); public Task FindByPathAsync(Guid l, string p, CancellationToken c) => Task.FromResult(artifacts.Values.FirstOrDefault(x => x.LibraryId == l && x.RelativePath.Equals(p, StringComparison.OrdinalIgnoreCase))); public Task FindByHashAsync(Guid l, long z, string h, CancellationToken c) => Task.FromResult(artifacts.Values.FirstOrDefault(x => x.LibraryId == l && x.Size == z && x.Sha256 == h)); public Task MarkMissingExceptAsync(Guid l, IReadOnlySet paths, DateTimeOffset at, CancellationToken c) { var missing = artifacts.Values.Where(x => x.LibraryId == l && x.State == ArtifactState.Present && !paths.Contains(x.RelativePath)).ToArray(); foreach (var a in missing) artifacts[a.Id] = a with { State = ArtifactState.Missing }; return Task.FromResult((long)missing.Length); } public Task> SearchArtifactsAsync(string? q, int p, int z, CancellationToken c) { var x = artifacts.Values.Where(a => q is null || a.RelativePath.Contains(q, StringComparison.OrdinalIgnoreCase)).ToArray(); return Task.FromResult(new Page(x, p, z, x.Length)); } public Task GetArtifactDetailsAsync(Guid id, CancellationToken c) => Task.FromResult(null); public Task GetDashboardSummaryAsync(CancellationToken c) => Task.FromResult(new DashboardSummary(libraries.Count, artifacts.Count, artifacts.Count, 0, 0, bundles.Count, artifacts.Values.Sum(x => x.Size), artifacts.Values.LongCount(x => x.Sha256 is not null), artifacts.Values.LongCount(x => x.MediaType != MediaType.Unknown), artifacts.Values.LongCount(x => x.MediaType == MediaType.Unknown), artifacts.Values.LongCount(x => x.State == ArtifactState.Missing), 0, 0, reviews.Values.LongCount(x => x.State != ReviewState.Resolved), findings.LongCount(x => x.Severity == Severity.Critical), scans.Values.LongCount(x => x.State is not (ScanState.Completed or ScanState.Failed or ScanState.Cancelled)), 0)); public Task GetStorageSummaryAsync(CancellationToken c) { var summaries = libraries.Values.Select(library => { var stored = artifacts.Values.Where(item => item.LibraryId == library.Id).ToArray(); return new LibraryStorageSummary(library.Id, library.Name, stored.LongLength, stored.Where(item => item.State == ArtifactState.Present).Sum(item => item.Size), stored.LongCount(item => item.State == ArtifactState.Missing)); }).ToArray(); return Task.FromResult(new StorageSummary(artifacts.Values.Sum(x => x.Size), 0, [], summaries)); } public Task GetHealthSummaryAsync(CancellationToken c) => Task.FromResult(new HealthSummary(0, 0, 0, 0, 0, 0, findings)); public Task> GetDuplicatesAsync(CancellationToken c) => Task.FromResult>(artifacts.Values.GroupBy(x => x.Sha256).Where(x => x.Key is not null && x.Count() > 1).SelectMany(x => x).ToArray()); public Task ReplaceArchiveMembersAsync(Guid artifactId, IReadOnlyList members, CancellationToken c) { archiveMembers[artifactId] = members; return Task.CompletedTask; } public Task> ListArchiveMembersAsync(Guid artifactId, CancellationToken c) => Task.FromResult(archiveMembers.GetValueOrDefault(artifactId) ?? []); public Task AddBundleAsync(Bundle b, CancellationToken c) { bundles[b.Id] = b; return Task.CompletedTask; } public Task> ListBundlesAsync(CancellationToken c) => Task.FromResult>(bundles.Values.ToArray()); public Task AddReviewAsync(ReviewItem r, CancellationToken c) { reviews.TryAdd(r.Id, r); return Task.CompletedTask; } public Task> ListReviewsAsync(CancellationToken c) => Task.FromResult>(reviews.Values.ToArray()); public Task> SearchReviewsAsync(ReviewState? state, string? reason, int p, int z, CancellationToken c) { var x = reviews.Values.Where(r => (state is null || r.State == state) && (reason is null || r.Reason == reason)).ToArray(); return Task.FromResult(new Page(x, p, z, x.Length)); } public Task SetReviewStateAsync(Guid id, ReviewState state, string actor, CancellationToken c) { reviews[id] = reviews[id] with { State = state }; return Task.FromResult(reviews[id]); } public Task ResolveReviewAsync(Guid id, string r, string a, CancellationToken c) { var before = reviews[id]; reviews[id] = before with { State = ReviewState.Resolved, Resolution = r }; return Task.FromResult(new ManualResolution(Guid.NewGuid(), id, r, a, System.Text.Json.JsonSerializer.Serialize(before), System.Text.Json.JsonSerializer.Serialize(reviews[id]), DateTimeOffset.UtcNow)); } public Task UndoResolutionAsync(Guid id, string actor, CancellationToken c) => Task.CompletedTask; public Task AddClaimAsync(MetadataClaim claim, CancellationToken c) => Task.CompletedTask; public Task> ListClaimsAsync(string entityType, Guid entityId, CancellationToken c) => Task.FromResult>([]); public Task CreateGameAsync(string title, string actor, CancellationToken c) => throw new NotSupportedException(); public Task DeleteGameAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException(); public Task UpdateGameAsync(Guid id, string title, long version, string actor, CancellationToken c) => throw new NotSupportedException(); public Task GetGameAsync(Guid id, CancellationToken c) => Task.FromResult(null); public Task> ListGamesAsync(CancellationToken c) => Task.FromResult>([]); public Task> SearchGamesAsync(GameQuery query, CancellationToken c) => Task.FromResult(new Page([], query.Page, query.PageSize, 0)); public Task> ListReleasesForGamesAsync(IReadOnlyList gameIds, CancellationToken c) => Task.FromResult>([]); public Task> SearchWishlistAsync(string? q, string? platform, WishlistPriority? priority, WishlistStatus? status, string? sort, int page, int pageSize, CancellationToken c) => Task.FromResult(new Page([], page, pageSize, 0)); public Task GetWishlistSummaryAsync(CancellationToken c) => Task.FromResult(new WishlistSummary(0, 0, 0, 0, 0)); public Task CreateWishlistItemAsync(WishlistInput input, string actor, CancellationToken c) => throw new NotSupportedException(); public Task UpdateWishlistItemAsync(Guid id, WishlistInput input, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException(); public Task DeleteWishlistItemAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => Task.CompletedTask; public Task ReconcileWishlistAsync(string actor, CancellationToken c) => Task.FromResult(0); public Task> ListReviewGroupsAsync(ReviewState state, CancellationToken c) => Task.FromResult>([]); public Task ResolveReviewGroupAsync(IReadOnlyList ids, int expectedCount, string resolution, string actor, CancellationToken c) => throw new NotSupportedException(); public Task ApplyReviewGroupActionAsync(IReadOnlyList ids, int expectedCount, ReviewBulkAction action, string? platform, string? pathPattern, string actor, CancellationToken c) => throw new NotSupportedException(); public Task DeferReviewGroupAsync(IReadOnlyList ids, int expectedCount, string reason, string actor, CancellationToken c) => throw new NotSupportedException(); public Task ReopenReviewGroupAsync(IReadOnlyList ids, int expectedCount, ReviewState sourceState, string reason, string actor, CancellationToken c) => throw new NotSupportedException(); public Task UndoReviewOperationAsync(Guid id, string actor, CancellationToken c) => Task.CompletedTask; public Task SynchronizeDiscoveredGamesAsync(Guid libraryId, CancellationToken c) => Task.FromResult(0); public Task UpsertGameArtworkAsync(GameArtwork artwork, CancellationToken c) => Task.CompletedTask; public Task GetSelectedGameArtworkAsync(Guid gameId, CancellationToken c, string? platform = null) => Task.FromResult(null); public Task> ListGameArtworkAsync(Guid gameId, CancellationToken c) => Task.FromResult>([]); public Task SelectGameArtworkAsync(Guid gameId, Guid artworkId, CancellationToken c) => Task.CompletedTask; public Task> ListArtworkNeedingReviewAsync(CancellationToken c) => Task.FromResult>([]); public Task> ListArtworkReviewItemsAsync(CancellationToken c) => Task.FromResult>([]); public Task EnqueueJobAsync(string kind, string payload, string? key, CancellationToken c) { var now = DateTimeOffset.UtcNow; var job = new BackgroundJob(Guid.NewGuid(), kind, JobState.Queued, payload, 0, null, 0, 3, now, now, now); jobs.Add(job); return Task.FromResult(job); } public Task LeaseJobAsync(string worker, TimeSpan lease, CancellationToken c) => Task.FromResult(null); public Task UpdateJobAsync(BackgroundJob job, CancellationToken c) => Task.CompletedTask; public Task> ListJobsAsync(CancellationToken c) => Task.FromResult>(jobs); public Task GetJobAsync(Guid id, CancellationToken c) => Task.FromResult(null); public Task RequestJobCancellationAsync(Guid id, CancellationToken c) => Task.CompletedTask; public Task RetryJobAsync(Guid id, CancellationToken c) => throw new NotSupportedException(); public Task UpsertProviderSnapshotAsync(ProviderSnapshot snapshot, CancellationToken c) => Task.CompletedTask; public Task> ListProviderSnapshotsAsync(CancellationToken c) => Task.FromResult>([]); public Task> ListPlatformsAsync(CancellationToken c) => Task.FromResult>([]); public Task CreateReleaseAsync(Guid gameId, string title, string? platform, string? region, string? revision, string actor, CancellationToken c) => throw new NotSupportedException(); public Task UpdateReleaseAsync(Guid id, string title, string? platform, string? region, string? revision, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException(); public Task DeleteReleaseAsync(Guid id, long expectedVersion, string actor, CancellationToken c) => throw new NotSupportedException(); public Task> ListReleasesAsync(Guid? gameId, CancellationToken c) => Task.FromResult>([]); public Task GetGameArtworkAsync(Guid gameId, CancellationToken c) => Task.FromResult(null); public Task> ListFindingsAsync(CancellationToken c) => Task.FromResult>(findings); public Task AddFindingAsync(Finding f, CancellationToken c) { findings.Add(f); return Task.CompletedTask; } public Task CreateSnapshotAsync(string n, CancellationToken c) => throw new NotSupportedException(); public Task> ListSnapshotsAsync(CancellationToken c) => Task.FromResult>([]); public Task GetSnapshotAsync(Guid id, CancellationToken c) => Task.FromResult(null); public Task GetOperationalSettingsAsync(CancellationToken c) => Task.FromResult(settings); public Task SaveOperationalSettingsAsync(OperationalSettings value, long expectedVersion, CancellationToken c) { settings = value with { Version = expectedVersion + 1 }; return Task.FromResult(settings); } } }