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 :
/// InboundAuthFailure → Denied;
/// 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);
}