using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Scheduler.Persistence.Postgres.Models; using StellaOps.Scheduler.Persistence.Postgres.Repositories; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scheduler.Persistence.Postgres.Tests; /// /// Fixed TimeProvider for deterministic tests. /// Returns a fixed UTC time regardless of wall-clock. /// internal sealed class FixedTimeProvider : TimeProvider { private readonly DateTimeOffset _fixedTime; public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime; public override DateTimeOffset GetUtcNow() => _fixedTime; } /// /// Integration tests verifying that Scheduler repositories honour the injected /// instead of relying on SQL NOW(). /// /// Each repository constructor accepts an optional TimeProvider? timeProvider = null /// parameter. When a set to a distinctive past date is /// injected, every timestamp column that the repository writes via @now must /// reflect that fixed date, not the database server clock. /// [Collection(SchedulerPostgresCollection.Name)] public sealed class TimeProviderIntegrationTests : IAsyncLifetime { private static readonly DateTimeOffset FixedTime = new(2020, 6, 15, 12, 0, 0, TimeSpan.Zero); private readonly SchedulerPostgresFixture _fixture; private readonly SchedulerDataSource _dataSource; public TimeProviderIntegrationTests(SchedulerPostgresFixture fixture) { _fixture = fixture; var options = fixture.Fixture.CreateOptions(); options.SchemaName = fixture.SchemaName; _dataSource = new SchedulerDataSource(Options.Create(options), NullLogger.Instance); } public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync()); public ValueTask DisposeAsync() => ValueTask.CompletedTask; // ----------------------------------------------------------------------- // DistributedLockRepository // ----------------------------------------------------------------------- [Trait("Category", TestCategories.Unit)] [Fact] public async Task TryAcquire_UsesTimeProvider_ForExpiresAt() { // Arrange var fixedTimeProvider = new FixedTimeProvider(FixedTime); var repository = new DistributedLockRepository( _dataSource, NullLogger.Instance, fixedTimeProvider); var lockKey = $"tp-lock-{Guid.NewGuid()}"; var tenantId = Guid.NewGuid().ToString(); var duration = TimeSpan.FromMinutes(5); // Act var acquired = await repository.TryAcquireAsync(tenantId, lockKey, "holder-1", duration); // Assert – acquisition must succeed acquired.Should().BeTrue(); // Read the lock back (same repository / same fixed TimeProvider so // the expires_at > @now check uses the same fixed time). var lockInfo = await repository.GetAsync(lockKey); lockInfo.Should().NotBeNull("the lock should be readable with the same TimeProvider"); // expires_at must equal FixedTime + duration (the INSERT sets expires_at = @now + @duration) lockInfo!.ExpiresAt.Should().BeCloseTo(FixedTime + duration, TimeSpan.FromSeconds(1)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task TryAcquire_OnConflict_UsesTimeProvider_ForAcquiredAtAndExpiresAt() { // Arrange — first acquire with a fixed past time to create the lock with a known expires_at var earlyTime = new DateTimeOffset(2020, 6, 15, 10, 0, 0, TimeSpan.Zero); var earlyProvider = new FixedTimeProvider(earlyTime); var earlyRepo = new DistributedLockRepository( _dataSource, NullLogger.Instance, earlyProvider); var lockKey = $"tp-conflict-{Guid.NewGuid()}"; var tenantId = Guid.NewGuid().ToString(); var shortDuration = TimeSpan.FromMilliseconds(200); await earlyRepo.TryAcquireAsync(tenantId, lockKey, "holder-old", shortDuration); // The lock's expires_at is earlyTime + 200ms = 2020-06-15T10:00:00.200Z // Now re-acquire with a later fixed time that exceeds the expires_at // This triggers the ON CONFLICT DO UPDATE where expires_at < @now var laterTime = new DateTimeOffset(2020, 6, 15, 12, 0, 0, TimeSpan.Zero); var laterProvider = new FixedTimeProvider(laterTime); var laterRepo = new DistributedLockRepository( _dataSource, NullLogger.Instance, laterProvider); var duration = TimeSpan.FromMinutes(10); // Act var reacquired = await laterRepo.TryAcquireAsync(tenantId, lockKey, "holder-new", duration); // Assert reacquired.Should().BeTrue("expired lock should be reacquirable"); var lockInfo = await laterRepo.GetAsync(lockKey); lockInfo.Should().NotBeNull(); // ON CONFLICT path sets acquired_at = @now lockInfo!.AcquiredAt.Should().BeCloseTo(laterTime, TimeSpan.FromSeconds(1)); // ON CONFLICT path sets expires_at = @now + @duration lockInfo.ExpiresAt.Should().BeCloseTo(laterTime + duration, TimeSpan.FromSeconds(1)); lockInfo.HolderId.Should().Be("holder-new"); } // ----------------------------------------------------------------------- // WorkerRepository // ----------------------------------------------------------------------- [Trait("Category", TestCategories.Unit)] [Fact] public async Task Heartbeat_UsesTimeProvider_ForLastHeartbeatAt() { // Arrange — insert a worker first (using system time so the row exists) var systemRepo = new WorkerRepository( _dataSource, NullLogger.Instance); var worker = new WorkerEntity { Id = $"tp-worker-hb-{Guid.NewGuid()}", Hostname = "test-host", Status = WorkerStatus.Active, JobTypes = ["scan"], MaxConcurrentJobs = 4 }; await systemRepo.UpsertAsync(worker); // Now heartbeat with a fixed TimeProvider var fixedTimeProvider = new FixedTimeProvider(FixedTime); var fixedRepo = new WorkerRepository( _dataSource, NullLogger.Instance, fixedTimeProvider); // Act var updated = await fixedRepo.HeartbeatAsync(worker.Id, 2); // Assert updated.Should().BeTrue(); // Read back and verify the timestamp var fetched = await fixedRepo.GetByIdAsync(worker.Id); fetched.Should().NotBeNull(); fetched!.LastHeartbeatAt.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1)); fetched.CurrentJobs.Should().Be(2); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Upsert_OnConflict_UsesTimeProvider_ForLastHeartbeatAt() { // Arrange — insert a worker first so the second upsert triggers ON CONFLICT var systemRepo = new WorkerRepository( _dataSource, NullLogger.Instance); var workerId = $"tp-worker-upsert-{Guid.NewGuid()}"; var worker = new WorkerEntity { Id = workerId, Hostname = "test-host", Status = WorkerStatus.Active, JobTypes = ["scan"], MaxConcurrentJobs = 4 }; await systemRepo.UpsertAsync(worker); // Second upsert with fixed TimeProvider triggers ON CONFLICT DO UPDATE // which sets last_heartbeat_at = @now var fixedTimeProvider = new FixedTimeProvider(FixedTime); var fixedRepo = new WorkerRepository( _dataSource, NullLogger.Instance, fixedTimeProvider); var updatedWorker = new WorkerEntity { Id = workerId, Hostname = "updated-host", Status = WorkerStatus.Active, JobTypes = ["scan", "sbom"], MaxConcurrentJobs = 8 }; // Act var returned = await fixedRepo.UpsertAsync(updatedWorker); // Assert — the returned entity should have last_heartbeat_at equal to our fixed time returned.Should().NotBeNull(); returned.LastHeartbeatAt.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1)); returned.Hostname.Should().Be("updated-host"); returned.MaxConcurrentJobs.Should().Be(8); // Also verify via a fresh read var fetched = await fixedRepo.GetByIdAsync(workerId); fetched.Should().NotBeNull(); fetched!.LastHeartbeatAt.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetStaleWorkers_UsesTimeProvider_ForStaleComparison() { // Arrange — create a worker with a heartbeat at the fixed (past) time var fixedTimeProvider = new FixedTimeProvider(FixedTime); var fixedRepo = new WorkerRepository( _dataSource, NullLogger.Instance, fixedTimeProvider); // Insert the worker first with system time so it exists var systemRepo = new WorkerRepository( _dataSource, NullLogger.Instance); var worker = new WorkerEntity { Id = $"tp-stale-{Guid.NewGuid()}", Hostname = "stale-host", Status = WorkerStatus.Active, JobTypes = ["scan"], MaxConcurrentJobs = 2 }; await systemRepo.UpsertAsync(worker); // Set heartbeat to fixed time (2020-06-15 12:00:00 UTC) await fixedRepo.HeartbeatAsync(worker.Id, 0); // Query with a "recent" fixed time — the worker's heartbeat at 2020 should be stale // relative to 2020-06-15 12:30:00 with a stale duration of 10 minutes var laterTime = new DateTimeOffset(2020, 6, 15, 12, 30, 0, TimeSpan.Zero); var laterProvider = new FixedTimeProvider(laterTime); var laterRepo = new WorkerRepository( _dataSource, NullLogger.Instance, laterProvider); // Act — stale duration of 10 min means heartbeats before 12:20 are stale var staleWorkers = await laterRepo.GetStaleWorkersAsync(TimeSpan.FromMinutes(10)); // Assert staleWorkers.Should().ContainSingle(w => w.Id == worker.Id); } }