using Microsoft.Extensions.Options; using ScadaLink.Commons.Types.Transport; using ScadaLink.Transport; using ScadaLink.Transport.Import; namespace ScadaLink.Transport.Tests.Import; public sealed class BundleSessionStoreTests { private static IOptions Options() => Microsoft.Extensions.Options.Options.Create(new TransportOptions()); private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new() { SessionId = Guid.NewGuid(), Manifest = StubManifest(), DecryptedContent = Array.Empty(), ExpiresAt = expiresAt, }; private static BundleManifest StubManifest() => new( BundleFormatVersion: 1, SchemaVersion: "1.0", CreatedAtUtc: DateTimeOffset.UnixEpoch, SourceEnvironment: "test", ExportedBy: "t", ScadaLinkVersion: "1", ContentHash: "0", Encryption: null, Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0), Contents: Array.Empty()); [Fact] public void Open_then_Get_returns_session() { var clock = new TestTimeProvider(DateTimeOffset.UtcNow); var sut = new BundleSessionStore(Options(), clock); var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30)); sut.Open(session); var fetched = sut.Get(session.SessionId); Assert.NotNull(fetched); Assert.Same(session, fetched); } [Fact] public void Get_after_TTL_returns_null_and_evicts() { var start = DateTimeOffset.UtcNow; var clock = new TestTimeProvider(start); var sut = new BundleSessionStore(Options(), clock); var session = SessionExpiringAt(start.AddMinutes(1)); sut.Open(session); clock.Advance(TimeSpan.FromMinutes(2)); Assert.Null(sut.Get(session.SessionId)); // Lookup with no time advancement now returns null again — proves the // entry was evicted, not merely filtered. Assert.Null(sut.Get(session.SessionId)); } [Fact] public void Remove_evicts_session() { var clock = new TestTimeProvider(DateTimeOffset.UtcNow); var sut = new BundleSessionStore(Options(), clock); var session = SessionExpiringAt(clock.GetUtcNow().AddHours(1)); sut.Open(session); sut.Remove(session.SessionId); Assert.Null(sut.Get(session.SessionId)); } [Fact] public void EvictExpired_removes_all_past_ttl() { var start = DateTimeOffset.UtcNow; var clock = new TestTimeProvider(start); var sut = new BundleSessionStore(Options(), clock); var keep = SessionExpiringAt(start.AddMinutes(30)); var dropA = SessionExpiringAt(start.AddSeconds(10)); var dropB = SessionExpiringAt(start.AddMinutes(1)); sut.Open(keep); sut.Open(dropA); sut.Open(dropB); clock.Advance(TimeSpan.FromMinutes(2)); sut.EvictExpired(); Assert.NotNull(sut.Get(keep.SessionId)); Assert.Null(sut.Get(dropA.SessionId)); Assert.Null(sut.Get(dropB.SessionId)); } [Fact] public void Three_failed_unlock_attempts_locks_session() { var clock = new TestTimeProvider(DateTimeOffset.UtcNow); var sut = new BundleSessionStore(Options(), clock); var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30)); sut.Open(session); var live = sut.Get(session.SessionId); Assert.NotNull(live); live!.FailedUnlockAttempts++; Assert.False(live.Locked); live.FailedUnlockAttempts++; Assert.False(live.Locked); live.FailedUnlockAttempts++; Assert.True(live.Locked); // Mutations are observable via re-fetch — confirms the store hands out // the shared reference (not a snapshot). var refetch = sut.Get(session.SessionId); Assert.NotNull(refetch); Assert.True(refetch!.Locked); } /// /// Minimal in-test with a mutable clock. Used in /// place of Microsoft.Extensions.TimeProvider.Testing (not in the /// central package list). Only is overridden; the /// store does not use timers, so the base implementations suffice elsewhere. /// private sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _now; public TestTimeProvider(DateTimeOffset start) { _now = start; } public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now = _now.Add(by); } }