chore(libs): infrastructure postgres host + attestation slicing + testkit

Shared infrastructure supporting the truthful runtime persistence cutover
sprints — no dedicated sprint owner, these libs are consumed by multiple
services.

- Infrastructure.Postgres: MigrationCategory + StartupMigrationHost +
  tests (MigrationExecution, Recording, Flags).
- AdvisoryAI.Attestation: slice AiAttestationService into partial files
  (Create/Read/Verify), align IAiAttestationStore + InMemory store,
  service tests.
- TestKit: ValkeyFixture for tests that need a shared valkey instance.
- Doctor/AdvisoryAI/IEvidenceSchemaRegistry: shared interface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-19 14:44:43 +03:00
parent 07cdba01cd
commit 87a5d2ee22
16 changed files with 230 additions and 142 deletions

View File

@@ -7,7 +7,7 @@ namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class AiAttestationService
{
/// <inheritdoc/>
public Task<AiAttestationResult> CreateRunAttestationAsync(
public async Task<AiAttestationResult> CreateRunAttestationAsync(
AiRunAttestation attestation,
bool sign = true,
CancellationToken ct = default)
@@ -24,15 +24,11 @@ public sealed partial class AiAttestationService
dsseEnvelope = CreateMockDsseEnvelope(AiRunAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.RunId,
AiRunAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_runAttestations[attestation.RunId] = stored;
await _store.StoreRunAttestationAsync(attestation, ct).ConfigureAwait(false);
if (dsseEnvelope is not null)
{
await _store.StoreSignedEnvelopeAsync(attestation.RunId, dsseEnvelope, ct).ConfigureAwait(false);
}
_logger.LogInformation(
"Created run attestation {RunId} with digest {Digest}, signed={Signed}",
@@ -40,7 +36,7 @@ public sealed partial class AiAttestationService
digest,
sign);
return Task.FromResult(new AiAttestationResult
return new AiAttestationResult
{
AttestationId = attestation.RunId,
Digest = digest,
@@ -48,11 +44,11 @@ public sealed partial class AiAttestationService
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/run/{attestation.RunId}",
CreatedAt = now
});
};
}
/// <inheritdoc/>
public Task<AiAttestationResult> CreateClaimAttestationAsync(
public async Task<AiAttestationResult> CreateClaimAttestationAsync(
AiClaimAttestation attestation,
bool sign = true,
CancellationToken ct = default)
@@ -67,22 +63,18 @@ public sealed partial class AiAttestationService
dsseEnvelope = CreateMockDsseEnvelope(AiClaimAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.ClaimId,
AiClaimAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_claimAttestations[attestation.ClaimId] = stored;
await _store.StoreClaimAttestationAsync(attestation, ct).ConfigureAwait(false);
if (dsseEnvelope is not null)
{
await _store.StoreSignedEnvelopeAsync(attestation.RunId, dsseEnvelope, ct).ConfigureAwait(false);
}
_logger.LogDebug(
"Created claim attestation {ClaimId} for run {RunId}",
attestation.ClaimId,
attestation.RunId);
return Task.FromResult(new AiAttestationResult
return new AiAttestationResult
{
AttestationId = attestation.ClaimId,
Digest = digest,
@@ -90,6 +82,6 @@ public sealed partial class AiAttestationService
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/claim/{attestation.ClaimId}",
CreatedAt = now
});
};
}
}

View File

@@ -1,5 +1,4 @@
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation;
@@ -9,47 +8,29 @@ public sealed partial class AiAttestationService
public Task<AiRunAttestation?> GetRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult<AiRunAttestation?>(null);
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
return Task.FromResult(attestation);
}
=> _store.GetRunAttestationAsync(runId, ct);
/// <inheritdoc/>
public Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
public async Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
string runId,
CancellationToken ct = default)
{
var claims = _claimAttestations.Values
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiClaimAttestation))
.Where(c => c != null && c.RunId == runId)
.Cast<AiClaimAttestation>()
.ToList();
return Task.FromResult<IReadOnlyList<AiClaimAttestation>>(claims);
var claims = await _store.GetClaimAttestationsAsync(runId, ct).ConfigureAwait(false);
return claims;
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
public async Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
string tenantId,
int limit = 100,
CancellationToken ct = default)
{
var attestations = _runAttestations.Values
.OrderByDescending(s => s.CreatedAt)
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiRunAttestation))
.Where(a => a != null && a.TenantId == tenantId)
.Cast<AiRunAttestation>()
var windowStart = DateTimeOffset.UnixEpoch;
var windowEnd = _timeProvider.GetUtcNow().AddYears(1);
var attestations = await _store.GetByTenantAsync(tenantId, windowStart, windowEnd, ct).ConfigureAwait(false);
return attestations
.OrderByDescending(attestation => attestation.CompletedAt)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<AiRunAttestation>>(attestations);
.ToArray();
}
}

View File

@@ -1,92 +1,65 @@
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class AiAttestationService
{
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
public async Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_runAttestations.TryGetValue(runId, out var stored))
var attestation = await _store.GetRunAttestationAsync(runId, ct).ConfigureAwait(false);
if (attestation is null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
return AiAttestationVerificationResult.Failure(
now,
$"Run attestation {runId} not found"));
}
// Verify digest
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
$"Run attestation {runId} not found");
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
var storedEnvelope = await _store.GetSignedEnvelopeAsync(runId, ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(computedDigest))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
return AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
digestValid: false);
}
// In production, verify signature via signer service
bool? signatureValid = stored.DsseEnvelope != null ? true : null;
_logger.LogDebug("Verified run attestation {RunId}", runId);
return Task.FromResult(AiAttestationVerificationResult.Success(
return AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
storedEnvelope is not null ? "ai-attestation-key" : null);
}
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
public async Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
string claimId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_claimAttestations.TryGetValue(claimId, out var stored))
var attestation = await _store.GetClaimAttestationAsync(claimId, ct).ConfigureAwait(false);
if (attestation is null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
return AiAttestationVerificationResult.Failure(
now,
$"Claim attestation {claimId} not found"));
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiClaimAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
$"Claim attestation {claimId} not found");
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
if (string.IsNullOrWhiteSpace(computedDigest))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
return AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
digestValid: false);
}
return Task.FromResult(AiAttestationVerificationResult.Success(
return AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
null);
}
}

View File

@@ -3,30 +3,32 @@
// </copyright>
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using StellaOps.AdvisoryAI.Attestation.Storage;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// In-memory implementation of AI attestation service.
/// Store-backed implementation of AI attestation service.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-003
/// </summary>
/// <remarks>
/// This implementation stores attestations in memory. For production,
/// use a database-backed implementation with signing integration.
/// Attestation records are persisted through <see cref="IAiAttestationStore"/>.
/// The default runtime wiring decides whether that store is durable or
/// in-memory for development/testing.
/// </remarks>
public sealed partial class AiAttestationService : IAiAttestationService
{
private readonly TimeProvider _timeProvider;
private readonly IAiAttestationStore _store;
private readonly ILogger<AiAttestationService> _logger;
private readonly ConcurrentDictionary<string, StoredAttestation> _runAttestations = new();
private readonly ConcurrentDictionary<string, StoredAttestation> _claimAttestations = new();
public AiAttestationService(
TimeProvider timeProvider,
IAiAttestationStore store,
ILogger<AiAttestationService> logger)
{
_timeProvider = timeProvider;
_store = store;
_logger = logger;
}
}

View File

@@ -13,6 +13,14 @@ public partial interface IAiAttestationStore
/// <returns>The attestation or null if not found.</returns>
Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct);
/// <summary>
/// Gets a claim attestation by claim ID.
/// </summary>
/// <param name="claimId">The claim ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The claim attestation or null if not found.</returns>
Task<AiClaimAttestation?> GetClaimAttestationAsync(string claimId, CancellationToken ct);
/// <summary>
/// Get all claim attestations for a run.
/// </summary>

View File

@@ -5,6 +5,13 @@ namespace StellaOps.AdvisoryAI.Attestation.Storage;
public sealed partial class InMemoryAiAttestationStore
{
/// <inheritdoc/>
public Task<AiClaimAttestation?> GetClaimAttestationAsync(string claimId, CancellationToken ct)
{
_claimAttestationsById.TryGetValue(claimId, out var attestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct)
{

View File

@@ -30,6 +30,7 @@ public sealed partial class InMemoryAiAttestationStore
claims.Add(attestation);
}
_claimAttestationsById[attestation.ClaimId] = attestation;
_digestIndex[attestation.ContentDigest] = attestation;
_logger.LogDebug(

View File

@@ -18,6 +18,7 @@ public sealed partial class InMemoryAiAttestationStore : IAiAttestationStore
private readonly ConcurrentDictionary<string, AiRunAttestation> _runAttestations = new();
private readonly ConcurrentDictionary<string, object> _signedEnvelopes = new();
private readonly ConcurrentDictionary<string, List<AiClaimAttestation>> _claimAttestations = new();
private readonly ConcurrentDictionary<string, AiClaimAttestation> _claimAttestationsById = new();
private readonly ConcurrentDictionary<string, AiClaimAttestation> _digestIndex = new();
private readonly ILogger<InMemoryAiAttestationStore> _logger;

View File

@@ -30,9 +30,36 @@ public sealed record EvidenceFieldSchema
}
/// <summary>
/// In-memory deterministic registry implementation.
/// Built-in deterministic registry implementation used by live runtime wiring.
/// </summary>
public sealed class BuiltInEvidenceSchemaRegistry : IEvidenceSchemaRegistry
{
private readonly EvidenceSchemaRegistryState _state = EvidenceSchemaRegistryDefaults.Create();
public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName) => _state.GetFieldSchema(checkId, fieldName);
public IReadOnlyDictionary<string, EvidenceFieldSchema> GetCheckSchemas(string checkId) => _state.GetCheckSchemas(checkId);
public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema) => _state.RegisterSchema(checkId, fieldName, schema);
}
/// <summary>
/// Mutable in-memory deterministic registry kept for tests and local composition.
/// </summary>
public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
{
private readonly EvidenceSchemaRegistryState _state = new();
public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName) => _state.GetFieldSchema(checkId, fieldName);
public IReadOnlyDictionary<string, EvidenceFieldSchema> GetCheckSchemas(string checkId) => _state.GetCheckSchemas(checkId);
public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema) => _state.RegisterSchema(checkId, fieldName, schema);
public void RegisterCommonSchemas() => EvidenceSchemaRegistryDefaults.RegisterCommonSchemas(_state);
}
internal sealed class EvidenceSchemaRegistryState
{
private readonly object _sync = new();
private readonly Dictionary<string, Dictionary<string, EvidenceFieldSchema>> _schemas =
@@ -88,10 +115,22 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
checkSchemas[fieldName] = schema;
}
}
}
public void RegisterCommonSchemas()
internal static class EvidenceSchemaRegistryDefaults
{
public static EvidenceSchemaRegistryState Create()
{
RegisterSchema("*", "connection_latency_ms", new EvidenceFieldSchema
var state = new EvidenceSchemaRegistryState();
RegisterCommonSchemas(state);
return state;
}
public static void RegisterCommonSchemas(EvidenceSchemaRegistryState state)
{
ArgumentNullException.ThrowIfNull(state);
state.RegisterSchema("*", "connection_latency_ms", new EvidenceFieldSchema
{
Description = "Time to establish connection.",
ExpectedRange = "< 1000",
@@ -100,7 +139,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
DiscriminatesFor = ["network_issue", "service_overloaded"]
});
RegisterSchema("*", "disk_usage_percent", new EvidenceFieldSchema
state.RegisterSchema("*", "disk_usage_percent", new EvidenceFieldSchema
{
Description = "Disk space utilization percentage.",
ExpectedRange = "< 80",
@@ -109,7 +148,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
DiscriminatesFor = ["disk_full", "log_growth"]
});
RegisterSchema("*", "memory_usage_percent", new EvidenceFieldSchema
state.RegisterSchema("*", "memory_usage_percent", new EvidenceFieldSchema
{
Description = "Memory utilization percentage.",
ExpectedRange = "< 85",
@@ -118,7 +157,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
DiscriminatesFor = ["memory_pressure", "load_spike"]
});
RegisterSchema("*", "cpu_usage_percent", new EvidenceFieldSchema
state.RegisterSchema("*", "cpu_usage_percent", new EvidenceFieldSchema
{
Description = "CPU utilization percentage.",
ExpectedRange = "< 80",
@@ -127,7 +166,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
DiscriminatesFor = ["cpu_saturation", "runaway_process"]
});
RegisterSchema("*", "queue_depth", new EvidenceFieldSchema
state.RegisterSchema("*", "queue_depth", new EvidenceFieldSchema
{
Description = "Pending items waiting in queue.",
ExpectedRange = "< 100",

View File

@@ -20,7 +20,7 @@ public enum MigrationCategory
Release,
/// <summary>
/// Seed data that is inserted once.
/// Optional seed/demo data that is inserted only when an operator runs it explicitly.
/// Prefix: S001-S999
/// </summary>
Seed,
@@ -83,10 +83,11 @@ public static class MigrationCategoryExtensions
/// Returns true if this migration should run automatically at startup.
/// </summary>
public static bool IsAutomatic(this MigrationCategory category) =>
category is MigrationCategory.Startup or MigrationCategory.Seed;
category is MigrationCategory.Startup;
/// <summary>
/// Returns true if this migration requires manual CLI execution.
/// Returns true if this migration requires manual CLI execution before startup may proceed.
/// Seed migrations are intentionally excluded because they are opt-in/manual only and must not block runtime.
/// </summary>
public static bool RequiresManualExecution(this MigrationCategory category) =>
category is MigrationCategory.Release or MigrationCategory.Data;

View File

@@ -15,8 +15,8 @@ namespace StellaOps.Infrastructure.Postgres.Migrations;
/// This service:
/// - Acquires an advisory lock to prevent concurrent migrations
/// - Validates checksums of already-applied migrations
/// - Blocks startup if pending release migrations exist
/// - Runs only Category A (startup) and seed migrations automatically
/// - Blocks startup if pending release/data migrations exist
/// - Runs only startup migrations automatically; seed migrations remain operator-invoked
/// </remarks>
public abstract class StartupMigrationHost : IHostedService
{
@@ -116,37 +116,64 @@ public abstract class StartupMigrationHost : IHostedService
}
}
// Step 5: Check for pending release migrations
var pendingRelease = allMigrations
// Step 5: Check for pending release/data migrations
var pendingManual = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
.Where(m => m.Category.RequiresManualExecution())
.ToList();
if (pendingRelease.Count > 0)
if (pendingManual.Count > 0)
{
_logger.LogError(
"Migration: {Count} pending release migration(s) require manual execution for {Module}:",
pendingRelease.Count, _moduleName);
"Migration: {Count} pending manual migration(s) require explicit execution before {Module} startup can converge:",
pendingManual.Count,
_moduleName);
foreach (var migration in pendingRelease)
foreach (var migration in pendingManual)
{
_logger.LogError(" - {Migration} (Category: {Category})", migration.Name, migration.Category);
}
_logger.LogError("Run: stellaops db migrate --module {Module} --category release", _moduleName);
foreach (var category in pendingManual
.Select(static migration => migration.Category)
.Distinct()
.OrderBy(static category => category.ToString(), StringComparer.Ordinal))
{
_logger.LogError(
"Run: stella system migrations-run --module {Module} --category {Category}",
_moduleName,
category.ToString().ToLowerInvariant());
}
if (_options.FailOnPendingReleaseMigrations)
{
_lifetime.StopApplication();
throw new InvalidOperationException(
$"Pending release migrations block startup for {_moduleName}. Run CLI migration first.");
$"Pending manual migrations block startup for {_moduleName}. Run CLI migration first.");
}
}
var pendingSeed = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
.Where(static m => m.Category == MigrationCategory.Seed)
.OrderBy(m => m.Name)
.ToList();
if (pendingSeed.Count > 0)
{
_logger.LogInformation(
"Migration: {Count} optional seed migration(s) are pending for {Module}. They remain manual-only and will not run at startup.",
pendingSeed.Count,
_moduleName);
_logger.LogInformation(
"Run manually when needed: stella system migrations-run --module {Module} --category seed",
_moduleName);
}
// Step 6: Execute pending startup migrations
var pendingStartup = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
.Where(m => m.Category.IsAutomatic())
.Where(static m => m.Category == MigrationCategory.Startup)
.OrderBy(m => m.Name)
.ToList();

View File

@@ -94,7 +94,10 @@ public sealed class ValkeyFixture : IAsyncLifetime
Port = _container.GetMappedPublicPort(6379);
ConnectionString = $"{Host}:{Port}";
_connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
var redisOptions = ConfigurationOptions.Parse(ConnectionString);
redisOptions.AllowAdmin = true;
_connection = await ConnectionMultiplexer.ConnectAsync(redisOptions);
}
/// <summary>
@@ -250,4 +253,3 @@ public sealed class ValkeyTestSession : IAsyncDisposable
}
}

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
@@ -24,7 +25,10 @@ public sealed partial class AiAttestationServiceTests
public AiAttestationServiceTests()
{
_timeProvider.SetUtcNow(FixedUtcNow);
_service = new AiAttestationService(_timeProvider, NullLogger<AiAttestationService>.Instance);
_service = new AiAttestationService(
_timeProvider,
new InMemoryAiAttestationStore(NullLogger<InMemoryAiAttestationStore>.Instance),
NullLogger<AiAttestationService>.Instance);
}
private static AiRunAttestation CreateSampleRunAttestation()

View File

@@ -8,7 +8,7 @@ public partial class MigrationCategoryTests
{
[Theory]
[InlineData(MigrationCategory.Startup, true)]
[InlineData(MigrationCategory.Seed, true)]
[InlineData(MigrationCategory.Seed, false)]
[InlineData(MigrationCategory.Release, false)]
[InlineData(MigrationCategory.Data, false)]
public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected)
@@ -31,15 +31,20 @@ public partial class MigrationCategoryTests
[Theory]
[InlineData(MigrationCategory.Startup)]
[InlineData(MigrationCategory.Release)]
[InlineData(MigrationCategory.Seed)]
[InlineData(MigrationCategory.Data)]
public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(MigrationCategory category)
public void BlockingCategories_AreEitherAutomaticOrManual(MigrationCategory category)
{
var isAutomatic = category.IsAutomatic();
var requiresManual = category.RequiresManualExecution();
// They should be opposite of each other
(isAutomatic ^ requiresManual).Should().BeTrue(
$"Category {category} should be either automatic OR manual, not both or neither");
$"Category {category} should be either automatic OR rollout-blocking manual, not both or neither");
}
[Fact]
public void Seed_IsManualOptInWithoutBlockingStartup()
{
MigrationCategory.Seed.IsAutomatic().Should().BeFalse();
MigrationCategory.Seed.RequiresManualExecution().Should().BeFalse();
}
}

View File

@@ -23,11 +23,11 @@ public sealed partial class StartupMigrationHostTests
var appliedCount = await GetAppliedMigrationCountAsync(schemaName);
appliedCount.Should().BeGreaterThan(0);
// Verify specific startup/seed migrations were applied (not release)
// Verify only startup migrations were auto-applied (not seed or release)
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
migrations.Should().Contain("002_add_column.sql");
migrations.Should().Contain("S001_seed_data.sql");
migrations.Should().NotContain("S001_seed_data.sql");
migrations.Should().NotContain("100_release_migration.sql"); // Release not auto-applied
}
@@ -100,6 +100,38 @@ public sealed partial class StartupMigrationHostTests
markerCount.Should().Be(1);
}
[Fact]
public async Task MigrationRunner_RunFromAssemblyAsync_WithSeedCategory_AppliesSeedOnlyWhenRequestedAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
await host.StartAsync(CancellationToken.None);
var runner = new MigrationRunner(
ConnectionString,
schemaName,
"TestRunner",
NullLogger<MigrationRunner>.Instance);
// Act
var result = await runner.RunFromAssemblyAsync(
typeof(StartupMigrationHostTests).Assembly,
resourcePrefix: "TestMigrations",
options: new MigrationRunOptions { CategoryFilter = MigrationCategory.Seed });
// Assert
result.Success.Should().BeTrue();
result.AppliedCount.Should().Be(1);
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("S001_seed_data.sql");
var seededRowCount = await GetRowCountAsync(schemaName, "test_table");
seededRowCount.Should().Be(1);
}
[Fact]
public async Task StartAsync_CreatesSchemaAndMigrationsTableAsync()
{

View File

@@ -1,4 +1,5 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
@@ -36,17 +37,29 @@ public sealed partial class StartupMigrationHostTests
}
[Fact]
public async Task StartAsync_SeedMigrations_RecordedAsSeedCategoryAsync()
public async Task MigrationRunner_RunFromAssemblyAsync_SeedMigrations_RecordSeedCategoryAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
var runner = new MigrationRunner(
ConnectionString,
schemaName,
"TestRunner",
NullLogger<MigrationRunner>.Instance);
// Act
var result = await runner.RunFromAssemblyAsync(
typeof(StartupMigrationHostTests).Assembly,
resourcePrefix: "TestMigrations",
options: new MigrationRunOptions { CategoryFilter = MigrationCategory.Seed });
// Assert
result.Success.Should().BeTrue();
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();