using Microsoft.Data.SqlClient; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; /// /// C7 (Task 2.5) data-projection tests for the CollapseAuditLogToCanonical /// migration. Verifies that the canonical column layout and the five persisted computed /// columns are correct after the migration has been applied: /// /// Action = "{Channel}.{Kind}" per . /// Category = Channel name per . /// Outcome derived via : /// InboundAuthFailureDenied; /// Status ∈ {Failed, Parked, Discarded} → Failure; /// else → Success. /// Empty Actor maps to NULL. /// DetailsJson produced by round-trips /// correctly and the five persisted computed columns (Kind, Status, /// SourceSiteId, ExecutionId, ParentExecutionId) resolve to /// the expected values via JSON_VALUE (IngestedAtUtc is computed but /// non-persisted, so not asserted here). /// /// /// /// /// The fixture applies the FULL migration history (via /// ), so this test exercises the post-migration /// canonical table shape. Rather than using a two-phase fixture (apply up to C4, /// seed, apply C5), the tests insert rows directly into the canonical table using /// — the same codec the migration's /// FOR JSON PATH, WITHOUT_ARRAY_WRAPPER projection is designed to match — /// and verify that the five persisted computed columns resolve correctly from DetailsJson. /// This validates the computed-column SQL expressions that are the source of truth /// for both the live table and SwitchOutPartitionAsync's staging table. /// /// /// Tests use + Skip.IfNot(...) so the /// runner reports them as Skipped (not Passed) when MSSQL is unreachable. /// /// public class CollapseAuditLogToCanonicalMigrationTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public CollapseAuditLogToCanonicalMigrationTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } /// /// A representative ApiOutbound.ApiCall row with populated domain fields. /// Verifies: Action, Category, Outcome projection; DetailsJson round-trip; /// Kind/Status/SourceSiteId/ExecutionId/ParentExecutionId computed columns. /// [SkippableFact] public async Task CanonicalRow_ApiOutbound_ComputedColumns_ResolveCorrectly() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var eventId = Guid.NewGuid(); var executionId = Guid.NewGuid(); var parentExecutionId = Guid.NewGuid(); var occurredAt = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc); var details = new AuditDetails { Channel = AuditChannel.ApiOutbound.ToString(), Kind = AuditKind.ApiCall.ToString(), Status = AuditStatus.Delivered.ToString(), ExecutionId = executionId, ParentExecutionId = parentExecutionId, SourceSiteId = "site-alpha", HttpStatus = 200, DurationMs = 42, RequestSummary = "{\"body\":\"hello\"}", PayloadTruncated = false, }; var detailsJson = AuditDetailsCodec.Serialize(details); var action = AuditFieldBuilders.BuildAction(AuditChannel.ApiOutbound, AuditKind.ApiCall); var category = AuditFieldBuilders.BuildCategory(AuditChannel.ApiOutbound); // AuditStatus.Delivered → Outcome "Success" (not InboundAuthFailure, not Failed/Parked/Discarded) var outcome = AuditOutcomeProjector.Project(AuditStatus.Delivered, AuditKind.ApiCall); var outcomeStr = outcome.ToString(); // "Success" await InsertCanonicalRowAsync( eventId, occurredAt, actor: "svc-account", action: action, outcome: outcomeStr, category: category, target: "ext-api.endpoint", sourceNode: "node-a", correlationId: null, detailsJson: detailsJson); // ── Verify canonical top-level columns ───────────────────────────── var row = await ReadCanonicalRowAsync(eventId, occurredAt); Assert.NotNull(row); Assert.Equal(action, row.Action); // "ApiOutbound.ApiCall" Assert.Equal(category, row.Category); // "ApiOutbound" Assert.Equal("Success", row.Outcome); // Delivered → Success Assert.Equal("svc-account", row.Actor); // ── Verify persisted computed columns (JSON_VALUE expressions) ────── Assert.Equal(AuditKind.ApiCall.ToString(), row.Kind); Assert.Equal(AuditStatus.Delivered.ToString(), row.Status); Assert.Equal("site-alpha", row.SourceSiteId); Assert.Equal(executionId, row.ExecutionId); Assert.Equal(parentExecutionId, row.ParentExecutionId); // ── Verify DetailsJson round-trips through codec ──────────────────── var roundTripped = AuditDetailsCodec.Deserialize(row.DetailsJson); Assert.Equal(AuditChannel.ApiOutbound.ToString(), roundTripped.Channel); Assert.Equal(AuditKind.ApiCall.ToString(), roundTripped.Kind); Assert.Equal(AuditStatus.Delivered.ToString(), roundTripped.Status); Assert.Equal("site-alpha", roundTripped.SourceSiteId); Assert.Equal(executionId, roundTripped.ExecutionId); Assert.Equal(parentExecutionId, roundTripped.ParentExecutionId); Assert.Equal(200, roundTripped.HttpStatus); Assert.Equal(42, roundTripped.DurationMs); Assert.False(roundTripped.PayloadTruncated); } /// /// An InboundAuthFailure row (channel = ApiInbound). /// The C5 migration projection rule: Kind=InboundAuthFailure → Outcome=Denied /// regardless of Status. Verifies this special-case projection is represented /// correctly in the canonical Outcome column. /// [SkippableFact] public async Task CanonicalRow_InboundAuthFailure_Outcome_IsDenied() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var eventId = Guid.NewGuid(); var occurredAt = new DateTime(2026, 6, 1, 13, 0, 0, DateTimeKind.Utc); var details = new AuditDetails { Channel = AuditChannel.ApiInbound.ToString(), Kind = AuditKind.InboundAuthFailure.ToString(), Status = AuditStatus.Failed.ToString(), PayloadTruncated = false, }; var detailsJson = AuditDetailsCodec.Serialize(details); var action = AuditFieldBuilders.BuildAction(AuditChannel.ApiInbound, AuditKind.InboundAuthFailure); var category = AuditFieldBuilders.BuildCategory(AuditChannel.ApiInbound); // InboundAuthFailure → Denied regardless of Status=Failed. var outcome = AuditOutcomeProjector.Project(AuditStatus.Failed, AuditKind.InboundAuthFailure); Assert.Equal("Denied", outcome.ToString()); // pre-condition: factory applies same rule await InsertCanonicalRowAsync( eventId, occurredAt, actor: null, // unauthenticated — Actor NULL action: action, outcome: outcome.ToString(), // "Denied" category: category, target: "/api/route", sourceNode: "central-a", correlationId: null, detailsJson: detailsJson); var row = await ReadCanonicalRowAsync(eventId, occurredAt); Assert.NotNull(row); Assert.Equal("Denied", row.Outcome); Assert.Null(row.Actor); // NULL Actor preserved // Computed columns Assert.Equal(AuditKind.InboundAuthFailure.ToString(), row.Kind); Assert.Equal(AuditStatus.Failed.ToString(), row.Status); } /// /// An ApiOutbound.ApiCall row with Status=Failed. /// Projection rule: Status ∈ {Failed, Parked, Discarded} → Outcome=Failure. /// Verifies the failure-branch projection that applies to non-auth failures. /// [SkippableFact] public async Task CanonicalRow_FailedStatus_Outcome_IsFailure() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var eventId = Guid.NewGuid(); var occurredAt = new DateTime(2026, 6, 1, 14, 0, 0, DateTimeKind.Utc); var details = new AuditDetails { Channel = AuditChannel.ApiOutbound.ToString(), Kind = AuditKind.ApiCall.ToString(), Status = AuditStatus.Failed.ToString(), ErrorMessage = "connection refused", PayloadTruncated = false, }; var detailsJson = AuditDetailsCodec.Serialize(details); var outcome = AuditOutcomeProjector.Project(AuditStatus.Failed, AuditKind.ApiCall); Assert.Equal("Failure", outcome.ToString()); // pre-condition await InsertCanonicalRowAsync( eventId, occurredAt, actor: "svc-account", action: AuditFieldBuilders.BuildAction(AuditChannel.ApiOutbound, AuditKind.ApiCall), outcome: outcome.ToString(), category: AuditFieldBuilders.BuildCategory(AuditChannel.ApiOutbound), target: "ext-api.endpoint", sourceNode: "node-b", correlationId: null, detailsJson: detailsJson); var row = await ReadCanonicalRowAsync(eventId, occurredAt); Assert.NotNull(row); Assert.Equal("Failure", row.Outcome); Assert.Equal(AuditStatus.Failed.ToString(), row.Status); // DetailsJson round-trip preserves ErrorMessage var roundTripped = AuditDetailsCodec.Deserialize(row.DetailsJson); Assert.Equal("connection refused", roundTripped.ErrorMessage); } // ── helpers ────────────────────────────────────────────────────────────── private async Task InsertCanonicalRowAsync( Guid eventId, DateTime occurredAt, string? actor, string action, string outcome, string category, string? target, string? sourceNode, Guid? correlationId, string detailsJson) { await using var conn = _fixture.OpenConnection(); await using var cmd = conn.CreateCommand(); cmd.CommandText = @" INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson) VALUES (@EventId, @OccurredAtUtc, @Actor, @Action, @Outcome, @Category, @Target, @SourceNode, @CorrelationId, @DetailsJson);"; cmd.Parameters.AddWithValue("@EventId", eventId); cmd.Parameters.AddWithValue("@OccurredAtUtc", occurredAt); cmd.Parameters.AddWithValue("@Actor", (object?)actor ?? DBNull.Value); cmd.Parameters.AddWithValue("@Action", action); cmd.Parameters.AddWithValue("@Outcome", outcome); cmd.Parameters.AddWithValue("@Category", category); cmd.Parameters.AddWithValue("@Target", (object?)target ?? DBNull.Value); cmd.Parameters.AddWithValue("@SourceNode", (object?)sourceNode ?? DBNull.Value); cmd.Parameters.AddWithValue("@CorrelationId", (object?)correlationId ?? DBNull.Value); cmd.Parameters.AddWithValue("@DetailsJson", detailsJson); await cmd.ExecuteNonQueryAsync(); } private async Task ReadCanonicalRowAsync(Guid eventId, DateTime occurredAt) { await using var conn = _fixture.OpenConnection(); await using var cmd = conn.CreateCommand(); // Read all canonical and computed columns for the inserted row. cmd.CommandText = @" SELECT Action, Category, Outcome, Actor, DetailsJson, Kind, Status, SourceSiteId, ExecutionId, ParentExecutionId FROM dbo.AuditLog WHERE EventId = @EventId AND OccurredAtUtc = @OccurredAtUtc;"; cmd.Parameters.AddWithValue("@EventId", eventId); cmd.Parameters.AddWithValue("@OccurredAtUtc", occurredAt); await using var reader = await cmd.ExecuteReaderAsync(); if (!await reader.ReadAsync()) { return null; } return new CanonicalRow( Action: reader.GetString(0), Category: reader.GetString(1), Outcome: reader.GetString(2), Actor: reader.IsDBNull(3) ? null : reader.GetString(3), DetailsJson: reader.IsDBNull(4) ? null : reader.GetString(4), Kind: reader.IsDBNull(5) ? null : reader.GetString(5), Status: reader.IsDBNull(6) ? null : reader.GetString(6), SourceSiteId: reader.IsDBNull(7) ? null : reader.GetString(7), ExecutionId: reader.IsDBNull(8) ? null : reader.GetGuid(8), ParentExecutionId: reader.IsDBNull(9) ? null : reader.GetGuid(9)); } private sealed record CanonicalRow( string Action, string Category, string Outcome, string? Actor, string? DetailsJson, string? Kind, string? Status, string? SourceSiteId, Guid? ExecutionId, Guid? ParentExecutionId); }