using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Policy.Persistence.Postgres; using StellaOps.Policy.Persistence.Postgres.Models; using StellaOps.Policy.Persistence.Postgres.Repositories; using StellaOps.TestKit; using Xunit; namespace StellaOps.Policy.Persistence.Tests; /// /// Verifies that repositories correctly use the injected /// instead of SQL NOW() for timestamp columns. Each test injects a /// set to a distinctive past date and asserts /// that persisted timestamps match the fixed time, not wall-clock time. /// [Collection(PolicyPostgresCollection.Name)] public sealed class TimeProviderIntegrationTests : IAsyncLifetime { /// /// A distinctive past date that could never be confused with wall-clock time. /// private static readonly DateTimeOffset FixedTime = new(2020, 6, 15, 12, 0, 0, TimeSpan.Zero); private readonly PolicyPostgresFixture _fixture; private readonly PolicyDataSource _dataSource; private readonly FixedTimeProvider _fixedTimeProvider; // Repositories under test (using fixed time) private readonly EvaluationRunRepository _evalRunRepo; private readonly ConflictRepository _conflictRepo; // Seed repositories (using system time -- only needed for FK seeding) private readonly PackRepository _packRepo; private readonly PackVersionRepository _packVersionRepo; private readonly string _tenantId = Guid.NewGuid().ToString(); private readonly Guid _packId = Guid.NewGuid(); private const int SeedPackVersion = 1; public TimeProviderIntegrationTests(PolicyPostgresFixture fixture) { _fixture = fixture; _fixedTimeProvider = new FixedTimeProvider(FixedTime); var options = fixture.Fixture.CreateOptions(); options.SchemaName = fixture.SchemaName; _dataSource = new PolicyDataSource(Options.Create(options), NullLogger.Instance); // Repositories that use the fixed time provider _evalRunRepo = new EvaluationRunRepository(_dataSource, NullLogger.Instance, _fixedTimeProvider); _conflictRepo = new ConflictRepository(_dataSource, NullLogger.Instance, _fixedTimeProvider); // Seed repositories -- use default (system) time; their timestamps are not under test _packRepo = new PackRepository(_dataSource, NullLogger.Instance); _packVersionRepo = new PackVersionRepository(_dataSource, NullLogger.Instance); } public async ValueTask InitializeAsync() { await _fixture.TruncateAllTablesAsync(); // Seed a pack and pack version (required FK for evaluation runs) var pack = new PackEntity { Id = _packId, TenantId = _tenantId, Name = "tp-pack", DisplayName = "TimeProvider Test Pack", ActiveVersion = SeedPackVersion, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, CreatedBy = "tests" }; await _packRepo.CreateAsync(pack); var packVersion = new PackVersionEntity { Id = Guid.NewGuid(), PackId = _packId, Version = SeedPackVersion, RulesHash = "seed-hash", IsPublished = true, PublishedAt = DateTimeOffset.UtcNow, PublishedBy = "tests", CreatedBy = "tests" }; await _packVersionRepo.CreateAsync(packVersion); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; // ----------------------------------------------------------------------- // EvaluationRunRepository -- MarkStartedAsync sets started_at via TimeProvider // ----------------------------------------------------------------------- [Trait("Category", TestCategories.Unit)] [Fact] public async Task MarkStarted_UsesTimeProvider_ForStartedAt() { // Arrange var run = CreateEvalRun(); await _evalRunRepo.CreateAsync(run); // Act var result = await _evalRunRepo.MarkStartedAsync(_tenantId, run.Id); // Assert result.Should().BeTrue(); var fetched = await _evalRunRepo.GetByIdAsync(_tenantId, run.Id); fetched.Should().NotBeNull(); fetched!.Status.Should().Be(EvaluationStatus.Running); fetched.StartedAt.Should().NotBeNull(); // The started_at timestamp must match the fixed time, not the current wall-clock time. fetched.StartedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1)); // Guard: the fixed time is far enough in the past that it cannot be confused with "now". fetched.StartedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1)); } // ----------------------------------------------------------------------- // EvaluationRunRepository -- MarkCompletedAsync sets completed_at via TimeProvider // ----------------------------------------------------------------------- [Trait("Category", TestCategories.Unit)] [Fact] public async Task MarkCompleted_UsesTimeProvider_ForCompletedAt() { // Arrange var run = CreateEvalRun(); await _evalRunRepo.CreateAsync(run); await _evalRunRepo.MarkStartedAsync(_tenantId, run.Id); // Act var result = await _evalRunRepo.MarkCompletedAsync( _tenantId, run.Id, EvaluationResult.Pass, score: 95.0m, findingsCount: 3, criticalCount: 0, highCount: 1, mediumCount: 1, lowCount: 1, durationMs: 250); // Assert result.Should().BeTrue(); var fetched = await _evalRunRepo.GetByIdAsync(_tenantId, run.Id); fetched.Should().NotBeNull(); fetched!.Status.Should().Be(EvaluationStatus.Completed); fetched.CompletedAt.Should().NotBeNull(); // The completed_at timestamp must match the fixed time. fetched.CompletedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1)); fetched.CompletedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1)); } // ----------------------------------------------------------------------- // EvaluationRunRepository -- MarkFailedAsync sets completed_at via TimeProvider // ----------------------------------------------------------------------- [Trait("Category", TestCategories.Unit)] [Fact] public async Task MarkFailed_UsesTimeProvider_ForCompletedAt() { // Arrange var run = CreateEvalRun(); await _evalRunRepo.CreateAsync(run); // Act var result = await _evalRunRepo.MarkFailedAsync(_tenantId, run.Id, "Timeout during evaluation"); // Assert result.Should().BeTrue(); var fetched = await _evalRunRepo.GetByIdAsync(_tenantId, run.Id); fetched.Should().NotBeNull(); fetched!.Status.Should().Be(EvaluationStatus.Failed); fetched.CompletedAt.Should().NotBeNull(); // The completed_at timestamp must match the fixed time. fetched.CompletedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1)); fetched.CompletedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1)); } // ----------------------------------------------------------------------- // ConflictRepository -- ResolveAsync sets resolved_at via TimeProvider // ----------------------------------------------------------------------- [Trait("Category", TestCategories.Unit)] [Fact] public async Task Resolve_UsesTimeProvider_ForResolvedAt() { // Arrange var conflict = new ConflictEntity { Id = Guid.NewGuid(), TenantId = _tenantId, ConflictType = "rule_overlap", Severity = "high", Status = "open", Description = "Rules A and B overlap on scope X", CreatedAt = DateTimeOffset.UtcNow, CreatedBy = "tests" }; await _conflictRepo.CreateAsync(conflict); // Act var result = await _conflictRepo.ResolveAsync( _tenantId, conflict.Id, "Merged rules", "admin"); // Assert result.Should().BeTrue(); var fetched = await _conflictRepo.GetByIdAsync(_tenantId, conflict.Id); fetched.Should().NotBeNull(); fetched!.Status.Should().Be("resolved"); fetched.ResolvedAt.Should().NotBeNull(); // The resolved_at timestamp must match the fixed time. fetched.ResolvedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1)); fetched.ResolvedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1)); } // ----------------------------------------------------------------------- // ConflictRepository -- DismissAsync sets resolved_at via TimeProvider // ----------------------------------------------------------------------- [Trait("Category", TestCategories.Unit)] [Fact] public async Task Dismiss_UsesTimeProvider_ForResolvedAt() { // Arrange var conflict = new ConflictEntity { Id = Guid.NewGuid(), TenantId = _tenantId, ConflictType = "precedence", Severity = "low", Status = "open", Description = "Rule precedence ambiguity", CreatedAt = DateTimeOffset.UtcNow, CreatedBy = "tests" }; await _conflictRepo.CreateAsync(conflict); // Act var result = await _conflictRepo.DismissAsync(_tenantId, conflict.Id, "operator"); // Assert result.Should().BeTrue(); var fetched = await _conflictRepo.GetByIdAsync(_tenantId, conflict.Id); fetched.Should().NotBeNull(); fetched!.Status.Should().Be("dismissed"); fetched.ResolvedAt.Should().NotBeNull(); // The resolved_at timestamp must match the fixed time. fetched.ResolvedAt!.Value.Should().BeCloseTo(FixedTime, TimeSpan.FromSeconds(1)); fetched.ResolvedAt.Value.Should().BeBefore(DateTimeOffset.UtcNow.AddYears(-1)); } // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- private EvaluationRunEntity CreateEvalRun() => new() { Id = Guid.NewGuid(), TenantId = _tenantId, ProjectId = "tp-project", PackId = _packId, PackVersion = SeedPackVersion, Status = EvaluationStatus.Pending }; /// /// A that always returns a fixed UTC time. /// Used to prove that repository methods obtain their timestamps from the /// injected provider rather than from SQL NOW(). /// private sealed class FixedTimeProvider : TimeProvider { private readonly DateTimeOffset _fixedTime; public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime; public override DateTimeOffset GetUtcNow() => _fixedTime; } }