using Ludarium.Application; using Ludarium.Domain; using Ludarium.Infrastructure; using Npgsql; using Testcontainers.PostgreSql; namespace Ludarium.IntegrationTests; /// /// What a scan concludes about the files that were not there any more. /// /// /// Reconciliation compared the stored state against the name "Present" while every other query in /// the store reads the enum's ordinal. The comparison matched no row, so a ROM deleted from the /// archive stayed Present: it kept its Play control, counted towards the archive's size and never /// appeared as missing. Only the real database can prove the ordinal contract, so it is proven here. /// public sealed class ArtifactReconciliationTests { [Fact] [Trait("Category", "Container")] public async Task AFileThatLeftTheArchiveIsMarkedMissing() { 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_reconciliation_test") .WithUsername("ludarium") .WithPassword("synthetic-test-password") .Build(); await postgres.StartAsync(); await using var dataSource = NpgsqlDataSource.Create(postgres.GetConnectionString()); var store = new PostgresStore(dataSource); await store.InitializeAsync(CancellationToken.None); var library = LibraryRoot.Create("Reconciliation fixture", Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ludarium-reconciliation-library")), LibraryKind.Rom) with { IsReadOnly = true, IsAvailable = true }; await store.UpsertLibraryAsync(library, CancellationToken.None); var observed = "roms/nes/Kept.nes"; var removed = "roms/nes/Deleted.nes"; foreach (var path in new[] { observed, removed }) await store.UpsertArtifactAsync( new(Guid.NewGuid(), library.Id, path, 24_592, DateTimeOffset.UtcNow.AddDays(-2), ArtifactState.Present, MediaType.Rom, Confidence.Deterministic, null, DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddDays(-2), null, "nes"), null, [], CancellationToken.None); var at = DateTimeOffset.UtcNow; var marked = await store.MarkMissingExceptAsync(library.Id, new HashSet([observed]), at, CancellationToken.None); Assert.Equal(1, marked); var gone = await store.FindByPathAsync(library.Id, removed, CancellationToken.None); // Reading it back through the store proves the written state is the shape the record expects. Assert.Equal(ArtifactState.Missing, gone!.State); Assert.Equal(2, gone.Version); var kept = await store.FindByPathAsync(library.Id, observed, CancellationToken.None); Assert.Equal(ArtifactState.Present, kept!.State); Assert.Equal(1, kept.Version); // The same ordinal contract is read by the storage summary, so the archive reports the loss. var summary = await store.GetStorageSummaryAsync(CancellationToken.None); var entry = Assert.Single(summary.Libraries, item => item.LibraryId == library.Id); Assert.Equal(1, entry.Missing); // A second scan that still cannot see the file leaves it alone rather than counting it twice. Assert.Equal(0, await store.MarkMissingExceptAsync(library.Id, new HashSet([observed]), at, CancellationToken.None)); } }