Files
git.stella-ops.org/src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/TimeProviderIntegrationTests.cs

297 lines
11 KiB
C#

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;
/// <summary>
/// Verifies that repositories correctly use the injected <see cref="TimeProvider"/>
/// instead of SQL NOW() for timestamp columns. Each test injects a
/// <see cref="FixedTimeProvider"/> set to a distinctive past date and asserts
/// that persisted timestamps match the fixed time, not wall-clock time.
/// </summary>
[Collection(PolicyPostgresCollection.Name)]
public sealed class TimeProviderIntegrationTests : IAsyncLifetime
{
/// <summary>
/// A distinctive past date that could never be confused with wall-clock time.
/// </summary>
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<PolicyDataSource>.Instance);
// Repositories that use the fixed time provider
_evalRunRepo = new EvaluationRunRepository(_dataSource, NullLogger<EvaluationRunRepository>.Instance, _fixedTimeProvider);
_conflictRepo = new ConflictRepository(_dataSource, NullLogger<ConflictRepository>.Instance, _fixedTimeProvider);
// Seed repositories -- use default (system) time; their timestamps are not under test
_packRepo = new PackRepository(_dataSource, NullLogger<PackRepository>.Instance);
_packVersionRepo = new PackVersionRepository(_dataSource, NullLogger<PackVersionRepository>.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
};
/// <summary>
/// A <see cref="TimeProvider"/> that always returns a fixed UTC time.
/// Used to prove that repository methods obtain their timestamps from the
/// injected provider rather than from SQL <c>NOW()</c>.
/// </summary>
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}