using FluentAssertions; using StellaOps.AirGap.Importer.Versioning; namespace StellaOps.AirGap.Importer.Tests.Versioning; public sealed class VersionMonotonicityCheckerTests { [Fact] public async Task CheckAsync_WhenNoCurrent_ShouldBeFirstActivation() { var store = new InMemoryBundleVersionStore(); var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-14T00:00:00Z"))); var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-14T00:00:00Z")); var result = await checker.CheckAsync("tenant-a", "offline-kit", incoming); result.IsMonotonic.Should().BeTrue(); result.ReasonCode.Should().Be("FIRST_ACTIVATION"); result.CurrentVersion.Should().BeNull(); result.CurrentBundleDigest.Should().BeNull(); } [Fact] public async Task CheckAsync_WhenOlder_ShouldBeNonMonotonic() { var store = new InMemoryBundleVersionStore(); await store.UpsertAsync(new BundleVersionRecord( TenantId: "tenant-a", BundleType: "offline-kit", VersionString: "2.0.0", Major: 2, Minor: 0, Patch: 0, Prerelease: null, BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"), BundleDigest: "sha256:current", ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"), WasForceActivated: false, ForceActivateReason: null)); var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-14T00:00:00Z"))); var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-14T00:00:00Z")); var result = await checker.CheckAsync("tenant-a", "offline-kit", incoming); result.IsMonotonic.Should().BeFalse(); result.ReasonCode.Should().Be("VERSION_NON_MONOTONIC"); result.CurrentVersion.Should().NotBeNull(); result.CurrentVersion!.SemVer.Should().Be("2.0.0"); } [Fact] public async Task RecordActivationAsync_WhenNonMonotonicWithoutForce_ShouldThrow() { var store = new InMemoryBundleVersionStore(); await store.UpsertAsync(new BundleVersionRecord( TenantId: "tenant-a", BundleType: "offline-kit", VersionString: "2.0.0", Major: 2, Minor: 0, Patch: 0, Prerelease: null, BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"), BundleDigest: "sha256:current", ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"), WasForceActivated: false, ForceActivateReason: null)); var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-15T00:00:00Z"))); var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-15T00:00:00Z")); var act = () => checker.RecordActivationAsync("tenant-a", "offline-kit", incoming, "sha256:new"); await act.Should().ThrowAsync(); } [Fact] public async Task RecordActivationAsync_WhenForced_ShouldWriteForceFields() { var store = new InMemoryBundleVersionStore(); await store.UpsertAsync(new BundleVersionRecord( TenantId: "tenant-a", BundleType: "offline-kit", VersionString: "2.0.0", Major: 2, Minor: 0, Patch: 0, Prerelease: null, BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"), BundleDigest: "sha256:current", ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"), WasForceActivated: false, ForceActivateReason: null)); var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-15T00:00:00Z"))); var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-15T00:00:00Z")); await checker.RecordActivationAsync( "tenant-a", "offline-kit", incoming, "sha256:new", wasForceActivated: true, forceActivateReason: "manual rollback permitted"); var current = await store.GetCurrentAsync("tenant-a", "offline-kit"); current.Should().NotBeNull(); current!.WasForceActivated.Should().BeTrue(); current.ForceActivateReason.Should().Be("manual rollback permitted"); current.BundleDigest.Should().Be("sha256:new"); } private sealed class InMemoryBundleVersionStore : IBundleVersionStore { private BundleVersionRecord? _current; private readonly List _history = new(); public Task GetCurrentAsync(string tenantId, string bundleType, CancellationToken ct = default) { return Task.FromResult(_current is not null && _current.TenantId.Equals(tenantId, StringComparison.Ordinal) && _current.BundleType.Equals(bundleType, StringComparison.Ordinal) ? _current : null); } public Task UpsertAsync(BundleVersionRecord record, CancellationToken ct = default) { _current = record; _history.Insert(0, record); return Task.CompletedTask; } public Task> GetHistoryAsync(string tenantId, string bundleType, int limit = 10, CancellationToken ct = default) { var items = _history .Where(r => r.TenantId.Equals(tenantId, StringComparison.Ordinal) && r.BundleType.Equals(bundleType, StringComparison.Ordinal)) .Take(limit) .ToArray(); return Task.FromResult>(items); } } private sealed class FixedTimeProvider : TimeProvider { private readonly DateTimeOffset _utcNow; public FixedTimeProvider(DateTimeOffset utcNow) { _utcNow = utcNow; } public override DateTimeOffset GetUtcNow() => _utcNow; } }