using Xunit; namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; /// /// SourceNode-stamping (#23) integration tests for the /// AddAuditLogSourceNode migration: applies the EF migrations to a /// freshly-created MSSQL test database on the running infra/mssql container and /// asserts that the central AuditLog table carries the new /// SourceNode varchar(64) NULL column AND a partition-aligned /// IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc) composite index. /// /// /// Mirrors the AddAuditLogParentExecutionId shape: column is an additive /// metadata-only ALTER TABLE … ADD; the new index is created via raw SQL /// so it lives on ps_AuditLog_Month(OccurredAtUtc) like every other /// IX_AuditLog_* index, preserving the partition-switch purge path. /// Tests pair with Skip.IfNot(...) /// so the runner reports them as Skipped (not Passed) when MSSQL is unreachable. /// The fixture applies the migrations once at construction time. /// public class AddAuditLogSourceNodeMigrationTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public AddAuditLogSourceNodeMigrationTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } [SkippableFact] public async Task AppliesMigration_AddsSourceNodeColumn_ToAuditLog() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var present = await ScalarAsync( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode' " + "AND TABLE_SCHEMA = 'dbo';"); Assert.Equal(1, present); } [SkippableFact] public async Task SourceNodeColumn_IsNullableVarchar64() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // varchar (ASCII), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.) // and design doc fixes the column at varchar(64). Catches an EF default to // nvarchar if the migration ever drops `unicode: false`. var dataType = await ScalarAsync( "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';"); Assert.Equal("varchar", dataType); var maxLength = await ScalarAsync( "SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';"); Assert.Equal(64, maxLength); var isNullable = await ScalarAsync( "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';"); Assert.Equal("YES", isNullable); } [SkippableFact] public async Task AppliesMigration_CreatesIxAuditLogNodeOccurredIndex() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Locked index name from the design doc / CLAUDE.md. var indexCount = await ScalarAsync( "SELECT COUNT(*) FROM sys.indexes i " + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + "WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred';"); Assert.Equal(1, indexCount); } [SkippableFact] public async Task IxAuditLogNodeOccurred_HasExpectedKeyColumnsInOrder() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Key columns in order: SourceNode, OccurredAtUtc. sys.index_columns.key_ordinal // gives the position in the index key (1-based); is_included_column = 0 means // it's part of the key, not an INCLUDE. var keyColumns = new List<(int Ordinal, string Name)>(); await using (var conn = _fixture.OpenConnection()) await using (var cmd = conn.CreateCommand()) { cmd.CommandText = "SELECT ic.key_ordinal, c.name " + "FROM sys.indexes i " + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + "INNER JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id " + "INNER JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id " + "WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred' " + " AND ic.is_included_column = 0 " + "ORDER BY ic.key_ordinal;"; await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { keyColumns.Add((reader.GetByte(0), reader.GetString(1))); } } Assert.Equal(2, keyColumns.Count); Assert.Equal("SourceNode", keyColumns[0].Name); Assert.Equal("OccurredAtUtc", keyColumns[1].Name); } [SkippableFact] public async Task IxAuditLogNodeOccurred_LivesOnPsAuditLogMonth_PartitionScheme() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Partition-aligned indexes are required so the AuditLog partition-switch // purge keeps working. Every other IX_AuditLog_* index lives on // ps_AuditLog_Month(OccurredAtUtc); the new one must too. var schemeName = await ScalarAsync( "SELECT ps.name FROM sys.indexes i " + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + "INNER JOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_id " + "WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred';"); Assert.Equal("ps_AuditLog_Month", schemeName); } // --- helpers ------------------------------------------------------------ private async Task ScalarAsync(string sql) { await using var conn = _fixture.OpenConnection(); await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; var result = await cmd.ExecuteScalarAsync(); if (result is null || result is DBNull) { return default!; } return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; } }