using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using StellaOps.Feedser.Storage.Mongo.Exporting; namespace StellaOps.Feedser.Storage.Mongo.Tests; public sealed class ExportStateManagerTests { [Fact] public async Task StoreFullExportInitializesBaseline() { var store = new InMemoryExportStateStore(); var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-07-20T12:00:00Z")); var manager = new ExportStateManager(store, timeProvider); var record = await manager.StoreFullExportAsync( exporterId: "export:json", exportId: "20240720T120000Z", exportDigest: "sha256:abcd", cursor: "cursor-1", targetRepository: "registry.local/json", exporterVersion: "1.0.0", resetBaseline: true, manifest: Array.Empty(), cancellationToken: CancellationToken.None); Assert.Equal("export:json", record.Id); Assert.Equal("20240720T120000Z", record.BaseExportId); Assert.Equal("sha256:abcd", record.BaseDigest); Assert.Equal("sha256:abcd", record.LastFullDigest); Assert.Null(record.LastDeltaDigest); Assert.Equal("cursor-1", record.ExportCursor); Assert.Equal("registry.local/json", record.TargetRepository); Assert.Equal("1.0.0", record.ExporterVersion); Assert.Equal(timeProvider.Now, record.UpdatedAt); } [Fact] public async Task StoreFullExport_ResetBaselineOverridesExisting() { var store = new InMemoryExportStateStore(); var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-07-20T12:00:00Z")); var manager = new ExportStateManager(store, timeProvider); await manager.StoreFullExportAsync( exporterId: "export:json", exportId: "20240720T120000Z", exportDigest: "sha256:base", cursor: "cursor-base", targetRepository: null, exporterVersion: "1.0.0", resetBaseline: true, manifest: Array.Empty(), cancellationToken: CancellationToken.None); timeProvider.Advance(TimeSpan.FromMinutes(5)); var withoutReset = await manager.StoreFullExportAsync( exporterId: "export:json", exportId: "20240720T120500Z", exportDigest: "sha256:new", cursor: "cursor-new", targetRepository: null, exporterVersion: "1.0.1", resetBaseline: false, manifest: Array.Empty(), cancellationToken: CancellationToken.None); Assert.Equal("20240720T120000Z", withoutReset.BaseExportId); Assert.Equal("sha256:base", withoutReset.BaseDigest); Assert.Equal("sha256:new", withoutReset.LastFullDigest); Assert.Equal("cursor-new", withoutReset.ExportCursor); Assert.Equal(timeProvider.Now, withoutReset.UpdatedAt); timeProvider.Advance(TimeSpan.FromMinutes(5)); var reset = await manager.StoreFullExportAsync( exporterId: "export:json", exportId: "20240720T121000Z", exportDigest: "sha256:final", cursor: "cursor-final", targetRepository: null, exporterVersion: "1.0.2", resetBaseline: true, manifest: Array.Empty(), cancellationToken: CancellationToken.None); Assert.Equal("20240720T121000Z", reset.BaseExportId); Assert.Equal("sha256:final", reset.BaseDigest); Assert.Equal("sha256:final", reset.LastFullDigest); Assert.Null(reset.LastDeltaDigest); Assert.Equal("cursor-final", reset.ExportCursor); Assert.Equal(timeProvider.Now, reset.UpdatedAt); } [Fact] public async Task StoreFullExport_ResetsBaselineWhenRepositoryChanges() { var store = new InMemoryExportStateStore(); var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-07-21T08:00:00Z")); var manager = new ExportStateManager(store, timeProvider); await manager.StoreFullExportAsync( exporterId: "export:json", exportId: "20240721T080000Z", exportDigest: "sha256:base", cursor: "cursor-base", targetRepository: "registry/v1/json", exporterVersion: "1.0.0", resetBaseline: true, manifest: Array.Empty(), cancellationToken: CancellationToken.None); timeProvider.Advance(TimeSpan.FromMinutes(10)); var updated = await manager.StoreFullExportAsync( exporterId: "export:json", exportId: "20240721T081000Z", exportDigest: "sha256:new", cursor: "cursor-new", targetRepository: "registry/v2/json", exporterVersion: "1.1.0", resetBaseline: false, manifest: Array.Empty(), cancellationToken: CancellationToken.None); Assert.Equal("20240721T081000Z", updated.BaseExportId); Assert.Equal("sha256:new", updated.BaseDigest); Assert.Equal("sha256:new", updated.LastFullDigest); Assert.Equal("registry/v2/json", updated.TargetRepository); } [Fact] public async Task StoreDeltaExportRequiresBaseline() { var store = new InMemoryExportStateStore(); var manager = new ExportStateManager(store); await Assert.ThrowsAsync(() => manager.StoreDeltaExportAsync( exporterId: "export:json", deltaDigest: "sha256:def", cursor: null, exporterVersion: "1.0.1", manifest: Array.Empty(), cancellationToken: CancellationToken.None)); } [Fact] public async Task StoreDeltaExportUpdatesExistingState() { var store = new InMemoryExportStateStore(); var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-07-20T12:00:00Z")); var manager = new ExportStateManager(store, timeProvider); await manager.StoreFullExportAsync( exporterId: "export:json", exportId: "20240720T120000Z", exportDigest: "sha256:abcd", cursor: "cursor-1", targetRepository: null, exporterVersion: "1.0.0", resetBaseline: true, manifest: Array.Empty(), cancellationToken: CancellationToken.None); timeProvider.Advance(TimeSpan.FromMinutes(10)); var delta = await manager.StoreDeltaExportAsync( exporterId: "export:json", deltaDigest: "sha256:ef01", cursor: "cursor-2", exporterVersion: "1.0.1", manifest: Array.Empty(), cancellationToken: CancellationToken.None); Assert.Equal("sha256:ef01", delta.LastDeltaDigest); Assert.Equal("cursor-2", delta.ExportCursor); Assert.Equal("1.0.1", delta.ExporterVersion); Assert.Equal(timeProvider.Now, delta.UpdatedAt); Assert.Equal("sha256:abcd", delta.LastFullDigest); } private sealed class InMemoryExportStateStore : IExportStateStore { private readonly Dictionary _records = new(StringComparer.Ordinal); public Task FindAsync(string id, CancellationToken cancellationToken) { _records.TryGetValue(id, out var record); return Task.FromResult(record); } public Task UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken) { _records[record.Id] = record; return Task.FromResult(record); } } private sealed class TestTimeProvider : TimeProvider { public TestTimeProvider(DateTimeOffset start) => Now = start; public DateTimeOffset Now { get; private set; } public void Advance(TimeSpan delta) => Now = Now.Add(delta); public override DateTimeOffset GetUtcNow() => Now; } }