stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -0,0 +1,57 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public partial class MigrationCategoryTests
{
[Fact]
public void GetCategory_NullMigrationName_ThrowsArgumentNullException()
{
var act = () => MigrationCategoryExtensions.GetCategory(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void GetCategory_EmptyMigrationName_ThrowsArgumentException()
{
var act = () => MigrationCategoryExtensions.GetCategory(string.Empty);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void GetCategory_WhitespaceMigrationName_ThrowsArgumentException()
{
var act = () => MigrationCategoryExtensions.GetCategory(" ");
act.Should().Throw<ArgumentException>();
}
[Theory]
[InlineData("no_prefix_migration.sql", MigrationCategory.Startup)]
[InlineData("migration.sql", MigrationCategory.Startup)]
[InlineData("abc_123.sql", MigrationCategory.Startup)]
public void GetCategory_UnknownPattern_DefaultsToStartup(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Theory]
[InlineData("migrations/subfolder/001_test.sql", MigrationCategory.Startup)]
[InlineData("100_release.SQL", MigrationCategory.Release)] // Different extension case
[InlineData("001_test", MigrationCategory.Startup)] // No extension
public void GetCategory_PathVariations_ExtractsCorrectly(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Fact]
public void GetCategory_ZeroPrefix_ReturnsStartup()
{
// 0 should default to Startup as per the switch expression
var result = MigrationCategoryExtensions.GetCategory("0_zero_prefix.sql");
result.Should().Be(MigrationCategory.Startup);
}
}

View File

@@ -0,0 +1,45 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public partial class MigrationCategoryTests
{
[Theory]
[InlineData(MigrationCategory.Startup, true)]
[InlineData(MigrationCategory.Seed, true)]
[InlineData(MigrationCategory.Release, false)]
[InlineData(MigrationCategory.Data, false)]
public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected)
{
var result = category.IsAutomatic();
result.Should().Be(expected);
}
[Theory]
[InlineData(MigrationCategory.Startup, false)]
[InlineData(MigrationCategory.Seed, false)]
[InlineData(MigrationCategory.Release, true)]
[InlineData(MigrationCategory.Data, true)]
public void RequiresManualExecution_ReturnsExpectedValue(MigrationCategory category, bool expected)
{
var result = category.RequiresManualExecution();
result.Should().Be(expected);
}
[Theory]
[InlineData(MigrationCategory.Startup)]
[InlineData(MigrationCategory.Release)]
[InlineData(MigrationCategory.Seed)]
[InlineData(MigrationCategory.Data)]
public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(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");
}
}

View File

@@ -0,0 +1,27 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public partial class MigrationCategoryTests
{
[Theory]
[InlineData("001_create_auth_schema.sql", MigrationCategory.Startup)]
[InlineData("002_create_tenants_table.sql", MigrationCategory.Startup)]
[InlineData("003_create_users_table.sql", MigrationCategory.Startup)]
[InlineData("004_add_audit_columns.sql", MigrationCategory.Startup)]
[InlineData("100_drop_legacy_auth_columns.sql", MigrationCategory.Release)]
[InlineData("101_migrate_user_roles.sql", MigrationCategory.Release)]
[InlineData("S001_default_admin_role.sql", MigrationCategory.Seed)]
[InlineData("S002_system_permissions.sql", MigrationCategory.Seed)]
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
[InlineData("DM002_MigratePasswordHashes.sql", MigrationCategory.Data)]
public void GetCategory_RealWorldMigrationNames_CategorizesCorrectly(
string migrationName,
MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
}

View File

@@ -0,0 +1,39 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public partial class MigrationCategoryTests
{
[Theory]
[InlineData("S001_default_roles.sql", MigrationCategory.Seed)]
[InlineData("S100_builtin_policies.sql", MigrationCategory.Seed)]
[InlineData("S999_last_seed.sql", MigrationCategory.Seed)]
[InlineData("s001_lowercase.sql", MigrationCategory.Seed)]
public void GetCategory_SeedMigrations_ReturnsSeed(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Theory]
[InlineData("Schema_setup.sql")] // S followed by non-digit
[InlineData("Setup_tables.sql")]
public void GetCategory_SPrefix_NotFollowedByDigit_ReturnsStartup(string migrationName)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(MigrationCategory.Startup);
}
[Theory]
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
[InlineData("DM100_MigrateUserPrefs.sql", MigrationCategory.Data)]
[InlineData("DM999_FinalDataMigration.sql", MigrationCategory.Data)]
[InlineData("dm001_lowercase.sql", MigrationCategory.Data)]
public void GetCategory_DataMigrations_ReturnsData(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
}

View File

@@ -1,12 +1,11 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public class MigrationCategoryTests
public partial class MigrationCategoryTests
{
#region GetCategory Tests - Startup Migrations (001-099)
[Theory]
[InlineData("001_initial_schema.sql", MigrationCategory.Startup)]
[InlineData("001_initial_schema", MigrationCategory.Startup)]
@@ -20,10 +19,6 @@ public class MigrationCategoryTests
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Release Migrations (100-199, 200+)
[Theory]
[InlineData("100_drop_legacy_columns.sql", MigrationCategory.Release)]
[InlineData("150_rename_table.sql", MigrationCategory.Release)]
@@ -36,170 +31,4 @@ public class MigrationCategoryTests
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Seed Migrations (S001-S999)
[Theory]
[InlineData("S001_default_roles.sql", MigrationCategory.Seed)]
[InlineData("S100_builtin_policies.sql", MigrationCategory.Seed)]
[InlineData("S999_last_seed.sql", MigrationCategory.Seed)]
[InlineData("s001_lowercase.sql", MigrationCategory.Seed)]
public void GetCategory_SeedMigrations_ReturnsSeed(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Theory]
[InlineData("Schema_setup.sql")] // S followed by non-digit
[InlineData("Setup_tables.sql")]
public void GetCategory_SPrefix_NotFollowedByDigit_ReturnsStartup(string migrationName)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(MigrationCategory.Startup);
}
#endregion
#region GetCategory Tests - Data Migrations (DM001-DM999)
[Theory]
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
[InlineData("DM100_MigrateUserPrefs.sql", MigrationCategory.Data)]
[InlineData("DM999_FinalDataMigration.sql", MigrationCategory.Data)]
[InlineData("dm001_lowercase.sql", MigrationCategory.Data)]
public void GetCategory_DataMigrations_ReturnsData(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Edge Cases
[Fact]
public void GetCategory_NullMigrationName_ThrowsArgumentNullException()
{
var act = () => MigrationCategoryExtensions.GetCategory(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void GetCategory_EmptyMigrationName_ThrowsArgumentException()
{
var act = () => MigrationCategoryExtensions.GetCategory(string.Empty);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void GetCategory_WhitespaceMigrationName_ThrowsArgumentException()
{
var act = () => MigrationCategoryExtensions.GetCategory(" ");
act.Should().Throw<ArgumentException>();
}
[Theory]
[InlineData("no_prefix_migration.sql", MigrationCategory.Startup)]
[InlineData("migration.sql", MigrationCategory.Startup)]
[InlineData("abc_123.sql", MigrationCategory.Startup)]
public void GetCategory_UnknownPattern_DefaultsToStartup(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Theory]
[InlineData("migrations/subfolder/001_test.sql", MigrationCategory.Startup)]
[InlineData("100_release.SQL", MigrationCategory.Release)] // Different extension case
[InlineData("001_test", MigrationCategory.Startup)] // No extension
public void GetCategory_PathVariations_ExtractsCorrectly(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Fact]
public void GetCategory_ZeroPrefix_ReturnsStartup()
{
// 0 should default to Startup as per the switch expression
var result = MigrationCategoryExtensions.GetCategory("0_zero_prefix.sql");
result.Should().Be(MigrationCategory.Startup);
}
#endregion
#region IsAutomatic Tests
[Theory]
[InlineData(MigrationCategory.Startup, true)]
[InlineData(MigrationCategory.Seed, true)]
[InlineData(MigrationCategory.Release, false)]
[InlineData(MigrationCategory.Data, false)]
public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected)
{
var result = category.IsAutomatic();
result.Should().Be(expected);
}
#endregion
#region RequiresManualExecution Tests
[Theory]
[InlineData(MigrationCategory.Startup, false)]
[InlineData(MigrationCategory.Seed, false)]
[InlineData(MigrationCategory.Release, true)]
[InlineData(MigrationCategory.Data, true)]
public void RequiresManualExecution_ReturnsExpectedValue(MigrationCategory category, bool expected)
{
var result = category.RequiresManualExecution();
result.Should().Be(expected);
}
#endregion
#region IsAutomatic and RequiresManualExecution are Mutually Exclusive
[Theory]
[InlineData(MigrationCategory.Startup)]
[InlineData(MigrationCategory.Release)]
[InlineData(MigrationCategory.Seed)]
[InlineData(MigrationCategory.Data)]
public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(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");
}
#endregion
#region Real-World Migration Name Tests
[Theory]
[InlineData("001_create_auth_schema.sql", MigrationCategory.Startup)]
[InlineData("002_create_tenants_table.sql", MigrationCategory.Startup)]
[InlineData("003_create_users_table.sql", MigrationCategory.Startup)]
[InlineData("004_add_audit_columns.sql", MigrationCategory.Startup)]
[InlineData("100_drop_legacy_auth_columns.sql", MigrationCategory.Release)]
[InlineData("101_migrate_user_roles.sql", MigrationCategory.Release)]
[InlineData("S001_default_admin_role.sql", MigrationCategory.Seed)]
[InlineData("S002_system_permissions.sql", MigrationCategory.Seed)]
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
[InlineData("DM002_MigratePasswordHashes.sql", MigrationCategory.Data)]
public void GetCategory_RealWorldMigrationNames_CategorizesCorrectly(
string migrationName,
MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
}

View File

@@ -0,0 +1,50 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
[Fact]
public async Task StartAsync_MultipleConcurrentInstances_OnlyOneRunsMigrationsAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var runCount = 0;
var lockObject = new object();
// Create 5 concurrent hosts
var tasks = Enumerable.Range(0, 5)
.Select(_ =>
{
var host = CreateTestHost(
schemaName,
options: new StartupMigrationOptions
{
LockTimeoutSeconds = 30,
FailOnPendingReleaseMigrations = false
});
return host.StartAsync(CancellationToken.None)
.ContinueWith(_ =>
{
lock (lockObject) { runCount++; }
});
})
.ToArray();
// Act
await Task.WhenAll(tasks);
// Assert - all should complete, but migrations applied only once
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
// Each migration should only appear once in the table
var counts = await GetMigrationAppliedCountsAsync(schemaName);
foreach (var count in counts.Values)
{
count.Should().Be(1);
}
}
}

View File

@@ -0,0 +1,95 @@
using FluentAssertions;
using Microsoft.Extensions.Hosting;
using Moq;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
[Fact]
public async Task StartAsync_LockTimeout_ThrowsWhenFailOnLockTimeoutTrueAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions
{
LockTimeoutSeconds = 1,
FailOnLockTimeout = true
};
// Hold the lock manually
await using var lockConn = new NpgsqlConnection(ConnectionString);
await lockConn.OpenAsync();
var lockKey = ComputeLockKey(schemaName);
await using var lockCmd = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", lockConn);
lockCmd.Parameters.AddWithValue("key", lockKey);
await lockCmd.ExecuteNonQueryAsync();
try
{
var lifetimeMock = new Mock<IHostApplicationLifetime>();
var host = CreateTestHost(schemaName, options: options, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
finally
{
// Release the lock
await using var unlockCmd = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)", lockConn);
unlockCmd.Parameters.AddWithValue("key", lockKey);
await unlockCmd.ExecuteNonQueryAsync();
}
}
[Fact]
public async Task StartAsync_LockTimeout_DoesNotThrowWhenFailOnLockTimeoutFalseAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions
{
LockTimeoutSeconds = 1,
FailOnLockTimeout = false
};
// Hold the lock manually
await using var lockConn = new NpgsqlConnection(ConnectionString);
await lockConn.OpenAsync();
var lockKey = ComputeLockKey(schemaName);
await using var lockCmd = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", lockConn);
lockCmd.Parameters.AddWithValue("key", lockKey);
await lockCmd.ExecuteNonQueryAsync();
try
{
var host = CreateTestHost(schemaName, options: options);
// Act - should not throw, just skip migrations
await host.StartAsync(CancellationToken.None);
// Assert - schema should NOT be created since lock wasn't acquired
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeFalse();
}
finally
{
// Release the lock
await using var unlockCmd = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)", lockConn);
unlockCmd.Parameters.AddWithValue("key", lockKey);
await unlockCmd.ExecuteNonQueryAsync();
}
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Hosting;
using Moq;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
[Fact]
public async Task StartAsync_WithChecksumMismatch_ThrowsAndStopsApplicationAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First, apply migrations normally
var host1 = CreateTestHost(schemaName, options: options);
await host1.StartAsync(CancellationToken.None);
// Corrupt a checksum in the database
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
// Try to run again with checksum validation (and still ignore release migrations)
var lifetimeMock = new Mock<IHostApplicationLifetime>();
var options2 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host2 = CreateTestHost(schemaName, options: options2, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host2.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
[Fact]
public async Task StartAsync_WithChecksumMismatch_WhenFailOnMismatchFalse_DoesNotThrowAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options1 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First, apply migrations normally
var host1 = CreateTestHost(schemaName, options: options1);
await host1.StartAsync(CancellationToken.None);
// Corrupt a checksum
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
// Try with checksum mismatch allowed
var options2 = new StartupMigrationOptions
{
FailOnChecksumMismatch = false,
FailOnPendingReleaseMigrations = false
};
var host2 = CreateTestHost(schemaName, options: options2);
// Act - should not throw
await host2.StartAsync(CancellationToken.None);
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Infrastructure.Postgres.Migrations;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
private TestMigrationHost CreateTestHost(
string schemaName,
StartupMigrationOptions? options = null,
IHostApplicationLifetime? lifetime = null)
{
return new TestMigrationHost(
connectionString: ConnectionString,
schemaName: schemaName,
moduleName: "Test",
migrationsAssembly: typeof(StartupMigrationHostTests).Assembly,
logger: NullLogger<TestMigrationHost>.Instance,
lifetime: lifetime ?? CreateMockLifetime(),
options: options);
}
private static IHostApplicationLifetime CreateMockLifetime()
{
var mock = new Mock<IHostApplicationLifetime>();
return mock.Object;
}
}

View File

@@ -0,0 +1,13 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
private static long ComputeLockKey(string schemaName)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(schemaName));
return BitConverter.ToInt64(hash, 0);
}
}

View File

@@ -0,0 +1,57 @@
using Npgsql;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
private async Task<int> GetAppliedMigrationCountAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT COUNT(*) FROM {schemaName}.schema_migrations",
conn);
var result = await cmd.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
private async Task<List<string>> GetAppliedMigrationNamesAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT migration_name FROM {schemaName}.schema_migrations ORDER BY migration_name",
conn);
var names = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
names.Add(reader.GetString(0));
}
return names;
}
private async Task<Dictionary<string, int>> GetMigrationAppliedCountsAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT migration_name, COUNT(*) FROM {schemaName}.schema_migrations GROUP BY migration_name",
conn);
var counts = new Dictionary<string, int>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
counts[reader.GetString(0)] = Convert.ToInt32(reader.GetInt64(1));
}
return counts;
}
}

View File

@@ -0,0 +1,48 @@
using Npgsql;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
private async Task<bool> SchemaExistsAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
conn);
cmd.Parameters.AddWithValue("name", schemaName);
var result = await cmd.ExecuteScalarAsync();
return result is true;
}
private async Task<bool> TableExistsAsync(string schemaName, string tableName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = @schema AND table_name = @table)",
conn);
cmd.Parameters.AddWithValue("schema", schemaName);
cmd.Parameters.AddWithValue("table", tableName);
var result = await cmd.ExecuteScalarAsync();
return result is true;
}
private async Task CorruptChecksumAsync(string schemaName, string migrationName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"UPDATE {schemaName}.schema_migrations SET checksum = 'corrupted_checksum' WHERE migration_name = @name",
conn);
cmd.Parameters.AddWithValue("name", migrationName);
await cmd.ExecuteNonQueryAsync();
}
}

View File

@@ -0,0 +1,90 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
[Fact]
public async Task StartAsync_WithPendingStartupMigrations_AppliesThemAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
// FailOnPendingReleaseMigrations = false because test assembly includes release migrations
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - Check that migrations table has records
var appliedCount = await GetAppliedMigrationCountAsync(schemaName);
appliedCount.Should().BeGreaterThan(0);
// Verify specific startup/seed migrations were applied (not 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("100_release_migration.sql"); // Release not auto-applied
}
[Fact]
public async Task StartAsync_WithAlreadyAppliedMigrations_SkipsThemAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First run - apply migrations
var host1 = CreateTestHost(schemaName, options: options);
await host1.StartAsync(CancellationToken.None);
var initialCount = await GetAppliedMigrationCountAsync(schemaName);
// Second run - should skip already applied
var host2 = CreateTestHost(schemaName, options: options);
await host2.StartAsync(CancellationToken.None);
// Assert - count should remain the same
var finalCount = await GetAppliedMigrationCountAsync(schemaName);
finalCount.Should().Be(initialCount);
}
[Fact]
public async Task StartAsync_CreatesSchemaAndMigrationsTableAsync()
{
// 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);
// Assert - schema should exist
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeTrue();
// Assert - migrations table should exist
var tableExists = await TableExistsAsync(schemaName, "schema_migrations");
tableExists.Should().BeTrue();
}
[Fact]
public async Task StartAsync_WithDisabled_SkipsMigrationsAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { Enabled = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - schema should NOT be created
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeFalse();
}
}

View File

@@ -0,0 +1,60 @@
using FluentAssertions;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
[Fact]
public async Task StartAsync_RecordsMigrationMetadataAsync()
{
// 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);
// Assert - check metadata is recorded
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT category, checksum, duration_ms, applied_by FROM {schemaName}.schema_migrations WHERE migration_name = '001_create_test_table.sql'",
conn);
await using var reader = await cmd.ExecuteReaderAsync();
reader.Read().Should().BeTrue();
reader.GetString(0).Should().Be("startup"); // category
reader.GetString(1).Should().NotBeNullOrEmpty(); // checksum
reader.GetInt32(2).Should().BeGreaterThanOrEqualTo(0); // duration_ms
reader.GetString(3).Should().NotBeNullOrEmpty(); // applied_by
}
[Fact]
public async Task StartAsync_SeedMigrations_RecordedAsSeedCategoryAsync()
{
// 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);
// Assert
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT category FROM {schemaName}.schema_migrations WHERE migration_name = 'S001_seed_data.sql'",
conn);
var category = await cmd.ExecuteScalarAsync();
category.Should().Be("seed");
}
}

View File

@@ -0,0 +1,44 @@
using FluentAssertions;
using Microsoft.Extensions.Hosting;
using Moq;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public sealed partial class StartupMigrationHostTests
{
[Fact]
public async Task StartAsync_WithPendingReleaseMigrations_ThrowsAndStopsApplicationAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var lifetimeMock = new Mock<IHostApplicationLifetime>();
// Default FailOnPendingReleaseMigrations = true, which should cause failure
// because the test assembly includes 100_release_migration.sql
var host = CreateTestHost(schemaName, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
[Fact]
public async Task StartAsync_WithPendingReleaseMigrations_WhenFailOnPendingFalse_DoesNotThrowAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act - should not throw
await host.StartAsync(CancellationToken.None);
// Assert - startup migrations should still be applied
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
migrations.Should().NotContain("100_release_migration.sql"); // Release not applied automatically
}
}

View File

@@ -1,12 +1,6 @@
using System.Reflection;
using FluentAssertions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.TestKit;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
@@ -14,7 +8,8 @@ namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
/// Integration tests for StartupMigrationHost.
/// Uses Testcontainers to spin up a real PostgreSQL instance.
/// </summary>
public sealed class StartupMigrationHostTests : IAsyncLifetime
[Trait("Category", TestCategories.Integration)]
public sealed partial class StartupMigrationHostTests : IAsyncLifetime
{
private PostgreSqlContainer? _container;
private string ConnectionString => _container?.GetConnectionString()
@@ -36,516 +31,4 @@ public sealed class StartupMigrationHostTests : IAsyncLifetime
await _container.DisposeAsync();
}
}
#region Migration Execution Tests
[Fact]
public async Task StartAsync_WithPendingStartupMigrations_AppliesThem()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
// FailOnPendingReleaseMigrations = false because test assembly includes release migrations
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - Check that migrations table has records
var appliedCount = await GetAppliedMigrationCountAsync(schemaName);
appliedCount.Should().BeGreaterThan(0);
// Verify specific startup/seed migrations were applied (not 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("100_release_migration.sql"); // Release not auto-applied
}
[Fact]
public async Task StartAsync_WithAlreadyAppliedMigrations_SkipsThem()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First run - apply migrations
var host1 = CreateTestHost(schemaName, options: options);
await host1.StartAsync(CancellationToken.None);
var initialCount = await GetAppliedMigrationCountAsync(schemaName);
// Second run - should skip already applied
var host2 = CreateTestHost(schemaName, options: options);
await host2.StartAsync(CancellationToken.None);
// Assert - count should remain the same
var finalCount = await GetAppliedMigrationCountAsync(schemaName);
finalCount.Should().Be(initialCount);
}
[Fact]
public async Task StartAsync_CreatesSchemaAndMigrationsTable()
{
// 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);
// Assert - schema should exist
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeTrue();
// Assert - migrations table should exist
var tableExists = await TableExistsAsync(schemaName, "schema_migrations");
tableExists.Should().BeTrue();
}
[Fact]
public async Task StartAsync_WithDisabled_SkipsMigrations()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { Enabled = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - schema should NOT be created
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeFalse();
}
#endregion
#region Release Migration Tests
[Fact]
public async Task StartAsync_WithPendingReleaseMigrations_ThrowsAndStopsApplication()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var lifetimeMock = new Mock<IHostApplicationLifetime>();
// Default FailOnPendingReleaseMigrations = true, which should cause failure
// because the test assembly includes 100_release_migration.sql
var host = CreateTestHost(schemaName, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
[Fact]
public async Task StartAsync_WithPendingReleaseMigrations_WhenFailOnPendingFalse_DoesNotThrow()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act - should not throw
await host.StartAsync(CancellationToken.None);
// Assert - startup migrations should still be applied
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
migrations.Should().NotContain("100_release_migration.sql"); // Release not applied automatically
}
#endregion
#region Checksum Validation Tests
[Fact]
public async Task StartAsync_WithChecksumMismatch_ThrowsAndStopsApplication()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First, apply migrations normally
var host1 = CreateTestHost(schemaName, options: options);
await host1.StartAsync(CancellationToken.None);
// Corrupt a checksum in the database
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
// Try to run again with checksum validation (and still ignore release migrations)
var lifetimeMock = new Mock<IHostApplicationLifetime>();
var options2 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host2 = CreateTestHost(schemaName, options: options2, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host2.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
[Fact]
public async Task StartAsync_WithChecksumMismatch_WhenFailOnMismatchFalse_DoesNotThrow()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options1 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First, apply migrations normally
var host1 = CreateTestHost(schemaName, options: options1);
await host1.StartAsync(CancellationToken.None);
// Corrupt a checksum
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
// Try with checksum mismatch allowed
var options2 = new StartupMigrationOptions
{
FailOnChecksumMismatch = false,
FailOnPendingReleaseMigrations = false
};
var host2 = CreateTestHost(schemaName, options: options2);
// Act - should not throw
await host2.StartAsync(CancellationToken.None);
}
#endregion
#region Advisory Lock Tests
[Fact]
public async Task StartAsync_MultipleConcurrentInstances_OnlyOneRunsMigrations()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var runCount = 0;
var lockObject = new object();
// Create 5 concurrent hosts
var tasks = Enumerable.Range(0, 5)
.Select(_ =>
{
var host = CreateTestHost(
schemaName,
options: new StartupMigrationOptions
{
LockTimeoutSeconds = 30,
FailOnPendingReleaseMigrations = false
});
return host.StartAsync(CancellationToken.None)
.ContinueWith(_ =>
{
lock (lockObject) { runCount++; }
});
})
.ToArray();
// Act
await Task.WhenAll(tasks);
// Assert - all should complete, but migrations applied only once
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
// Each migration should only appear once in the table
var counts = await GetMigrationAppliedCountsAsync(schemaName);
foreach (var count in counts.Values)
{
count.Should().Be(1);
}
}
[Fact]
public async Task StartAsync_LockTimeout_ThrowsWhenFailOnLockTimeoutTrue()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions
{
LockTimeoutSeconds = 1,
FailOnLockTimeout = true
};
// Hold the lock manually
await using var lockConn = new NpgsqlConnection(ConnectionString);
await lockConn.OpenAsync();
var lockKey = ComputeLockKey(schemaName);
await using var lockCmd = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", lockConn);
lockCmd.Parameters.AddWithValue("key", lockKey);
await lockCmd.ExecuteNonQueryAsync();
try
{
var lifetimeMock = new Mock<IHostApplicationLifetime>();
var host = CreateTestHost(schemaName, options: options, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
finally
{
// Release the lock
await using var unlockCmd = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)", lockConn);
unlockCmd.Parameters.AddWithValue("key", lockKey);
await unlockCmd.ExecuteNonQueryAsync();
}
}
[Fact]
public async Task StartAsync_LockTimeout_DoesNotThrowWhenFailOnLockTimeoutFalse()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions
{
LockTimeoutSeconds = 1,
FailOnLockTimeout = false
};
// Hold the lock manually
await using var lockConn = new NpgsqlConnection(ConnectionString);
await lockConn.OpenAsync();
var lockKey = ComputeLockKey(schemaName);
await using var lockCmd = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", lockConn);
lockCmd.Parameters.AddWithValue("key", lockKey);
await lockCmd.ExecuteNonQueryAsync();
try
{
var host = CreateTestHost(schemaName, options: options);
// Act - should not throw, just skip migrations
await host.StartAsync(CancellationToken.None);
// Assert - schema should NOT be created since lock wasn't acquired
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeFalse();
}
finally
{
// Release the lock
await using var unlockCmd = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)", lockConn);
unlockCmd.Parameters.AddWithValue("key", lockKey);
await unlockCmd.ExecuteNonQueryAsync();
}
}
#endregion
#region Migration Recording Tests
[Fact]
public async Task StartAsync_RecordsMigrationMetadata()
{
// 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);
// Assert - check metadata is recorded
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT category, checksum, duration_ms, applied_by FROM {schemaName}.schema_migrations WHERE migration_name = '001_create_test_table.sql'",
conn);
await using var reader = await cmd.ExecuteReaderAsync();
reader.Read().Should().BeTrue();
reader.GetString(0).Should().Be("startup"); // category
reader.GetString(1).Should().NotBeNullOrEmpty(); // checksum
reader.GetInt32(2).Should().BeGreaterThanOrEqualTo(0); // duration_ms
reader.GetString(3).Should().NotBeNullOrEmpty(); // applied_by
}
[Fact]
public async Task StartAsync_SeedMigrations_RecordedAsSeedCategory()
{
// 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);
// Assert
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT category FROM {schemaName}.schema_migrations WHERE migration_name = 'S001_seed_data.sql'",
conn);
var category = await cmd.ExecuteScalarAsync();
category.Should().Be("seed");
}
#endregion
#region Helper Methods
private TestMigrationHost CreateTestHost(
string schemaName,
StartupMigrationOptions? options = null,
IHostApplicationLifetime? lifetime = null)
{
return new TestMigrationHost(
connectionString: ConnectionString,
schemaName: schemaName,
moduleName: "Test",
migrationsAssembly: typeof(StartupMigrationHostTests).Assembly,
logger: NullLogger<TestMigrationHost>.Instance,
lifetime: lifetime ?? CreateMockLifetime(),
options: options);
}
private static IHostApplicationLifetime CreateMockLifetime()
{
var mock = new Mock<IHostApplicationLifetime>();
return mock.Object;
}
private async Task<int> GetAppliedMigrationCountAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT COUNT(*) FROM {schemaName}.schema_migrations",
conn);
var result = await cmd.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
private async Task<List<string>> GetAppliedMigrationNamesAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT migration_name FROM {schemaName}.schema_migrations ORDER BY migration_name",
conn);
var names = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
names.Add(reader.GetString(0));
}
return names;
}
private async Task<Dictionary<string, int>> GetMigrationAppliedCountsAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT migration_name, COUNT(*) FROM {schemaName}.schema_migrations GROUP BY migration_name",
conn);
var counts = new Dictionary<string, int>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
counts[reader.GetString(0)] = Convert.ToInt32(reader.GetInt64(1));
}
return counts;
}
private async Task<bool> SchemaExistsAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
conn);
cmd.Parameters.AddWithValue("name", schemaName);
var result = await cmd.ExecuteScalarAsync();
return result is true;
}
private async Task<bool> TableExistsAsync(string schemaName, string tableName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = @schema AND table_name = @table)",
conn);
cmd.Parameters.AddWithValue("schema", schemaName);
cmd.Parameters.AddWithValue("table", tableName);
var result = await cmd.ExecuteScalarAsync();
return result is true;
}
private async Task CorruptChecksumAsync(string schemaName, string migrationName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"UPDATE {schemaName}.schema_migrations SET checksum = 'corrupted_checksum' WHERE migration_name = @name",
conn);
cmd.Parameters.AddWithValue("name", migrationName);
await cmd.ExecuteNonQueryAsync();
}
private static long ComputeLockKey(string schemaName)
{
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(schemaName));
return BitConverter.ToInt64(hash, 0);
}
#endregion
}
/// <summary>
/// Concrete test implementation of StartupMigrationHost.
/// Uses embedded resources from the test assembly.
/// </summary>
internal sealed class TestMigrationHost : StartupMigrationHost
{
public TestMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions? options)
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
{
}
}

View File

@@ -0,0 +1,25 @@
using System.Reflection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Migrations;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
/// <summary>
/// Concrete test implementation of StartupMigrationHost.
/// Uses embedded resources from the test assembly.
/// </summary>
internal sealed class TestMigrationHost : StartupMigrationHost
{
public TestMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions? options)
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
{
}
}

View File

@@ -0,0 +1,34 @@
using FluentAssertions;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests;
public sealed partial class PostgresFixtureTests
{
[Fact]
public async Task Dispose_DropsSchemaAsync()
{
// Arrange
var connectionString = _container!.GetConnectionString();
string schemaName;
// Create and dispose fixture
{
await using var fixture = PostgresFixtureFactory.CreateRandom(connectionString);
await fixture.InitializeAsync();
schemaName = fixture.SchemaName;
}
// Assert - schema should not exist
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
conn);
cmd.Parameters.AddWithValue("name", schemaName);
var exists = await cmd.ExecuteScalarAsync();
exists.Should().Be(false);
}
}

View File

@@ -0,0 +1,25 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests;
public sealed partial class PostgresFixtureTests
{
[Fact]
public async Task Initialize_CreatesSchemaAsync()
{
// Arrange
var connectionString = _container!.GetConnectionString();
await using var fixture = PostgresFixtureFactory.Create(connectionString,
nameof(Initialize_CreatesSchemaAsync));
// Act
await fixture.InitializeAsync();
// Assert
var options = fixture.CreateOptions();
var expectedPrefix = $"test_{nameof(Initialize_CreatesSchemaAsync).ToLowerInvariant()}_";
options.SchemaName.Should().StartWith(expectedPrefix);
}
}

View File

@@ -0,0 +1,38 @@
using FluentAssertions;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests;
public sealed partial class PostgresFixtureTests
{
[Fact]
public async Task TruncateAllTables_ClearsTablesAsync()
{
// Arrange
var connectionString = _container!.GetConnectionString();
await using var fixture = PostgresFixtureFactory.CreateRandom(connectionString);
await fixture.InitializeAsync();
// Create a test table and insert data
await fixture.ExecuteSqlAsync($"""
CREATE TABLE {fixture.SchemaName}.test_table (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
INSERT INTO {fixture.SchemaName}.test_table (name) VALUES ('test1'), ('test2');
""");
// Act
await fixture.TruncateAllTablesAsync();
// Assert - table should be empty
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT COUNT(*) FROM {fixture.SchemaName}.test_table", conn);
var count = await cmd.ExecuteScalarAsync();
count.Should().Be(0L);
}
}

View File

@@ -1,17 +1,15 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.TestKit;
using Testcontainers.PostgreSql;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Infrastructure.Postgres.Tests;
/// <summary>
/// Integration tests for PostgresFixture.
/// Uses Testcontainers to spin up a real PostgreSQL instance.
/// </summary>
public sealed class PostgresFixtureTests : IAsyncLifetime
[Trait("Category", TestCategories.Integration)]
public sealed partial class PostgresFixtureTests : IAsyncLifetime
{
private PostgreSqlContainer? _container;
@@ -31,79 +29,4 @@ public sealed class PostgresFixtureTests : IAsyncLifetime
await _container.DisposeAsync();
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Initialize_CreatesSchema()
{
// Arrange
var connectionString = _container!.GetConnectionString();
await using var fixture = PostgresFixtureFactory.Create(connectionString, nameof(Initialize_CreatesSchema));
// Act
await fixture.InitializeAsync();
// Assert
var options = fixture.CreateOptions();
options.SchemaName.Should().StartWith("test_initialize_createsschema_");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TruncateAllTables_ClearsTables()
{
// Arrange
var connectionString = _container!.GetConnectionString();
await using var fixture = PostgresFixtureFactory.CreateRandom(connectionString);
await fixture.InitializeAsync();
// Create a test table and insert data
await fixture.ExecuteSqlAsync($"""
CREATE TABLE {fixture.SchemaName}.test_table (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
INSERT INTO {fixture.SchemaName}.test_table (name) VALUES ('test1'), ('test2');
""");
// Act
await fixture.TruncateAllTablesAsync();
// Assert - table should be empty
await using var conn = new Npgsql.NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new Npgsql.NpgsqlCommand(
$"SELECT COUNT(*) FROM {fixture.SchemaName}.test_table", conn);
var count = await cmd.ExecuteScalarAsync();
count.Should().Be(0L);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Dispose_DropsSchema()
{
// Arrange
var connectionString = _container!.GetConnectionString();
string schemaName;
// Create and dispose fixture
{
await using var fixture = PostgresFixtureFactory.CreateRandom(connectionString);
await fixture.InitializeAsync();
schemaName = fixture.SchemaName;
}
// Assert - schema should not exist
await using var conn = new Npgsql.NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new Npgsql.NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
conn);
cmd.Parameters.AddWithValue("name", schemaName);
var exists = await cmd.ExecuteScalarAsync();
exists.Should().Be(false);
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0028-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
| AUDIT-0028-A | DONE | Waived (test project; revalidated 2026-01-08). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-2026-02-02 | DONE | Split tests into partials <=100 lines; async naming + using sort; class-level Integration traits; schema prefix aligned to async name; ConfigureAwait skipped (test code); dotnet test (70 passed). |