Search/AdvisoryAI and DAL conversion to EF finishes up. Preparation for microservices consolidation.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Policy.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guard tests ensuring the EF Core compiled model is real (not a stub)
|
||||
/// and contains all expected entity type registrations.
|
||||
/// </summary>
|
||||
public sealed class CompiledModelGuardTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompiledModel_Instance_IsNotNull()
|
||||
{
|
||||
PolicyDbContextModel.Instance.Should().NotBeNull(
|
||||
"compiled model must be generated via 'dotnet ef dbcontext optimize', not a stub");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompiledModel_HasExpectedEntityTypeCount()
|
||||
{
|
||||
var entityTypes = PolicyDbContextModel.Instance.GetEntityTypes().ToList();
|
||||
entityTypes.Should().HaveCount(24,
|
||||
"policy compiled model must contain exactly 24 entity types (regenerate with 'dotnet ef dbcontext optimize' if count differs)");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(typeof(PackEntity))]
|
||||
[InlineData(typeof(PackVersionEntity))]
|
||||
[InlineData(typeof(RuleEntity))]
|
||||
[InlineData(typeof(RiskProfileEntity))]
|
||||
[InlineData(typeof(EvaluationRunEntity))]
|
||||
[InlineData(typeof(ExplanationEntity))]
|
||||
[InlineData(typeof(SnapshotEntity))]
|
||||
[InlineData(typeof(ViolationEventEntity))]
|
||||
[InlineData(typeof(ConflictEntity))]
|
||||
[InlineData(typeof(LedgerExportEntity))]
|
||||
[InlineData(typeof(WorkerResultEntity))]
|
||||
[InlineData(typeof(ExceptionEntity))]
|
||||
[InlineData(typeof(BudgetLedgerEntity))]
|
||||
[InlineData(typeof(BudgetEntryEntity))]
|
||||
[InlineData(typeof(ExceptionApprovalRequestEntity))]
|
||||
[InlineData(typeof(ExceptionApprovalAuditEntity))]
|
||||
[InlineData(typeof(ExceptionApprovalRuleEntity))]
|
||||
[InlineData(typeof(PolicyAuditEntity))]
|
||||
[InlineData(typeof(TrustedKeyEntity))]
|
||||
[InlineData(typeof(GateBypassAuditEntity))]
|
||||
[InlineData(typeof(GateDecisionEntity))]
|
||||
[InlineData(typeof(ReplayAuditEntity))]
|
||||
[InlineData(typeof(AdvisorySourceImpactEntity))]
|
||||
[InlineData(typeof(AdvisorySourceConflictEntity))]
|
||||
public void CompiledModel_ContainsEntityType(Type entityType)
|
||||
{
|
||||
var found = PolicyDbContextModel.Instance.FindEntityType(entityType);
|
||||
found.Should().NotBeNull(
|
||||
$"compiled model must contain entity type '{entityType.Name}' — regenerate if missing");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompiledModel_EntityTypes_HaveTableNames()
|
||||
{
|
||||
var entityTypes = PolicyDbContextModel.Instance.GetEntityTypes();
|
||||
foreach (var entityType in entityTypes)
|
||||
{
|
||||
var tableName = entityType.GetTableName();
|
||||
tableName.Should().NotBeNullOrWhiteSpace(
|
||||
$"entity type '{entityType.ClrType.Name}' must have a table name configured");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,7 +303,8 @@ public sealed class PolicyMigrationTests : IAsyncLifetime
|
||||
{
|
||||
var assembly = typeof(PolicyDataSource).Assembly;
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(n => n.Contains("Migrations") && n.EndsWith(".sql"))
|
||||
.Where(n => n.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)
|
||||
&& !n.Contains("_archived", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(n => n);
|
||||
|
||||
return resourceNames;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Policy.Persistence;
|
||||
using StellaOps.Policy.Persistence.Postgres;
|
||||
@@ -27,6 +28,15 @@ public sealed class PolicyPostgresFixture : PostgresIntegrationFixture, ICollect
|
||||
=> typeof(PolicyDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Policy";
|
||||
|
||||
/// <summary>
|
||||
/// Policy migration SQL hardcodes the <c>policy.</c> schema prefix on all DDL
|
||||
/// and the repositories also use <c>policy.</c> in raw SQL. Use the canonical
|
||||
/// schema name so search_path, EF Core, and raw SQL all agree.
|
||||
/// </summary>
|
||||
protected override PostgresFixture CreateFixtureInstance(
|
||||
string connectionString, string moduleName, ILogger logger)
|
||||
=> new PostgresFixture(connectionString, "policy", logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user