using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// /// C4 (Task 2.5) hot-path + drain tests for 's /// two-table site schema. Exercise the Channel-based enqueue, the background /// writer's per-event canonical(audit_event) + sidecar /// (audit_forward_state) INSERTs, duplicate-EventId swallowing, the /// IsCachedKind drain split, the four reads, and the /// / /// sidecar flips. /// public class SqliteAuditWriterWriteTests { private static (SqliteAuditWriter writer, string dataSource) CreateWriter( string testName, int? channelCapacity = null, INodeIdentityProvider? nodeIdentity = null) { var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared"; var opts = new SqliteAuditWriterOptions { DatabasePath = dataSource }; if (channelCapacity is int cap) { opts.ChannelCapacity = cap; } // Default identity provider returns null — existing tests pre-date // SourceNode stamping and have no expectation about it. New stamping // tests pass a real provider via the parameter. var identity = nodeIdentity ?? new FakeNodeIdentityProvider(); var writer = new SqliteAuditWriter( Options.Create(opts), NullLogger.Instance, identity, connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); return (writer, dataSource); } private static SqliteConnection OpenVerifierConnection(string dataSource) { var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); connection.Open(); return connection; } /// /// Reads the sidecar ForwardState for one EventId (the column moved off /// the single legacy table onto audit_forward_state in C4). /// private static string? ReadForwardState(string dataSource, Guid eventId) { using var connection = OpenVerifierConnection(dataSource); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT ForwardState FROM audit_forward_state WHERE EventId = $id;"; cmd.Parameters.AddWithValue("$id", eventId.ToString()); return cmd.ExecuteScalar() as string; } /// Sidecar ForwardState → row-count, grouped (replaces the legacy single-table GROUP BY). private static Dictionary ForwardStateCounts(string dataSource) { using var connection = OpenVerifierConnection(dataSource); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM audit_forward_state GROUP BY ForwardState;"; using var reader = cmd.ExecuteReader(); var byState = new Dictionary(); while (reader.Read()) { byState[reader.GetString(0)] = reader.GetInt64(1); } return byState; } // C4 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared // factory. The SQLite writer stores the 10 canonical fields directly in // audit_event and writes a Pending sidecar row into audit_forward_state, with // IsCachedKind precomputed from the event's Kind. Reads recompose the canonical // record directly from audit_event's columns. private static AuditEvent NewEvent( Guid? id = null, DateTime? occurredAtUtc = null, Guid? executionId = null, string? sourceNode = null) => ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered, eventId: id ?? Guid.NewGuid(), occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow, executionId: executionId, sourceNode: sourceNode); /// A cached-lifecycle event (IsCachedKind=1) — drains via the cached read surface. private static AuditEvent NewCachedEvent( Guid? id = null, DateTime? occurredAtUtc = null, AuditKind kind = AuditKind.ApiCallCached) // Status is independent of IsCachedKind (which is derived from Kind); // Submitted is the natural first-row status for a cached lifecycle. => ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: kind, status: AuditStatus.Submitted, eventId: id ?? Guid.NewGuid(), occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow); [Fact] public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending() { var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending)); await using var _ = writer; var evt = NewEvent(); await writer.WriteAsync(evt); // Canonical row landed in audit_event. using var connection = OpenVerifierConnection(dataSource); using var eventCmd = connection.CreateCommand(); eventCmd.CommandText = "SELECT Action FROM audit_event WHERE EventId = $id;"; eventCmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); Assert.Equal(evt.Action, eventCmd.ExecuteScalar() as string); // Sidecar row landed Pending. Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId)); } [Fact] public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read() { var (writer, _) = CreateWriter(nameof(WriteAsync_Roundtrips_Canonical_Fields_Through_Read)); await using var _w = writer; var evt = NewEvent() with { Target = "target-1", Actor = "user-1" }; await writer.WriteAsync(evt); var rows = await writer.ReadPendingAsync(limit: 10); var row = Assert.Single(rows); Assert.Equal(evt.EventId, row.EventId); Assert.Equal(evt.OccurredAtUtc, row.OccurredAtUtc); Assert.Equal("user-1", row.Actor); Assert.Equal(evt.Action, row.Action); Assert.Equal(evt.Outcome, row.Outcome); Assert.Equal(evt.Category, row.Category); Assert.Equal("target-1", row.Target); Assert.Equal(evt.CorrelationId, row.CorrelationId); // DetailsJson is stored verbatim and round-trips byte-for-byte. Assert.Equal(evt.DetailsJson, row.DetailsJson); } [Fact] public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions() { var (writer, dataSource) = CreateWriter(nameof(WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions)); await using var _ = writer; var events = Enumerable.Range(0, 1000).Select(_ => NewEvent()).ToList(); await Parallel.ForEachAsync(events, new ParallelOptions { MaxDegreeOfParallelism = 16 }, async (evt, ct) => await writer.WriteAsync(evt, ct)); using var connection = OpenVerifierConnection(dataSource); using var eventCmd = connection.CreateCommand(); eventCmd.CommandText = "SELECT COUNT(*) FROM audit_event;"; Assert.Equal(1000, Convert.ToInt64(eventCmd.ExecuteScalar())); // Every canonical row has its matching sidecar row. using var sidecarCmd = connection.CreateCommand(); sidecarCmd.CommandText = "SELECT COUNT(*) FROM audit_forward_state;"; Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar())); } [Fact] public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException() { var (writer, dataSource) = CreateWriter(nameof(WriteAsync_DuplicateEventId_FirstWriteWins_NoException)); await using var _ = writer; var sharedId = Guid.NewGuid(); var first = NewEvent(sharedId) with { Target = "first" }; var second = NewEvent(sharedId) with { Target = "second" }; await writer.WriteAsync(first); await writer.WriteAsync(second); using var connection = OpenVerifierConnection(dataSource); using var countCmd = connection.CreateCommand(); countCmd.CommandText = "SELECT COUNT(*) FROM audit_event WHERE EventId = $id;"; countCmd.Parameters.AddWithValue("$id", sharedId.ToString()); Assert.Equal(1, Convert.ToInt64(countCmd.ExecuteScalar())); // The sidecar likewise gained exactly one row (the canonical PK throws // before the sidecar insert runs, so neither table double-inserts). using var sidecarCmd = connection.CreateCommand(); sidecarCmd.CommandText = "SELECT COUNT(*) FROM audit_forward_state WHERE EventId = $id;"; sidecarCmd.Parameters.AddWithValue("$id", sharedId.ToString()); Assert.Equal(1, Convert.ToInt64(sidecarCmd.ExecuteScalar())); using var targetCmd = connection.CreateCommand(); targetCmd.CommandText = "SELECT Target FROM audit_event WHERE EventId = $id;"; targetCmd.Parameters.AddWithValue("$id", sharedId.ToString()); Assert.Equal("first", targetCmd.ExecuteScalar() as string); } [Fact] public async Task WriteAsync_ForcesSidecarForwardStatePending() { var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending)); await using var _ = writer; // C4 (Task 2.5): ForwardState is not a field on the canonical record; a // fresh event's sidecar row defaults to Pending on INSERT. var evt = NewEvent(); await writer.WriteAsync(evt); Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId)); } // ----- IsCachedKind drain split (precomputed at insert) ----- // [Fact] public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0() { var (writer, dataSource) = CreateWriter(nameof(WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0)); await using var _ = writer; var cached = NewCachedEvent(); // ApiCallCached → cached var nonCached = NewEvent(); // ApiCall → not cached await writer.WriteAsync(cached); await writer.WriteAsync(nonCached); using var connection = OpenVerifierConnection(dataSource); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT IsCachedKind FROM audit_forward_state WHERE EventId = $id;"; var p = cmd.Parameters.Add("$id", SqliteType.Text); p.Value = cached.EventId.ToString(); Assert.Equal(1L, Convert.ToInt64(cmd.ExecuteScalar())); p.Value = nonCached.EventId.ToString(); Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar())); } [Theory] [InlineData(AuditKind.CachedSubmit)] [InlineData(AuditKind.ApiCallCached)] [InlineData(AuditKind.DbWriteCached)] [InlineData(AuditKind.CachedResolve)] public async Task CachedKinds_DrainVia_ReadPendingCachedTelemetry_Not_ReadPending(AuditKind kind) { var (writer, _) = CreateWriter($"{nameof(CachedKinds_DrainVia_ReadPendingCachedTelemetry_Not_ReadPending)}-{kind}"); await using var _w = writer; var cached = NewCachedEvent(kind: kind); await writer.WriteAsync(cached); // The cached kind appears in the cached read surface... var cachedRows = await writer.ReadPendingCachedTelemetryAsync(limit: 10); Assert.Single(cachedRows, r => r.EventId == cached.EventId); // ...and NOT in the audit-only read surface. var pendingRows = await writer.ReadPendingAsync(limit: 10); Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId); } [Fact] public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry() { var (writer, _) = CreateWriter(nameof(NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry)); await using var _w = writer; var nonCached = NewEvent(); // ApiCall — not a cached kind await writer.WriteAsync(nonCached); var pendingRows = await writer.ReadPendingAsync(limit: 10); Assert.Single(pendingRows, r => r.EventId == nonCached.EventId); var cachedRows = await writer.ReadPendingCachedTelemetryAsync(limit: 10); Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId); } [Fact] public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN() { var (writer, _) = CreateWriter(nameof(ReadPendingAsync_Returns_OldestFirst_LimitedToN)); await using var _writer = writer; var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); var evts = new[] { NewEvent(occurredAtUtc: baseTime.AddSeconds(5)), NewEvent(occurredAtUtc: baseTime.AddSeconds(1)), NewEvent(occurredAtUtc: baseTime.AddSeconds(3)), NewEvent(occurredAtUtc: baseTime.AddSeconds(2)), NewEvent(occurredAtUtc: baseTime.AddSeconds(4)), }; foreach (var e in evts) { await writer.WriteAsync(e); } var rows = await writer.ReadPendingAsync(limit: 3); Assert.Equal(3, rows.Count); Assert.Equal(baseTime.AddSeconds(1), rows[0].OccurredAtUtc); Assert.Equal(baseTime.AddSeconds(2), rows[1].OccurredAtUtc); Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc); } [Fact] public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded() { var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsSidecarRowsToForwarded)); await using var _ = writer; var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; foreach (var id in ids) { await writer.WriteAsync(NewEvent(id)); } await writer.MarkForwardedAsync(ids); var byState = ForwardStateCounts(dataSource); Assert.Equal(3, byState[AuditForwardState.Forwarded.ToString()]); Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString())); } [Fact] public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc() { var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc)); await using var _ = writer; var evt = NewEvent(); await writer.WriteAsync(evt); await writer.MarkForwardedAsync(new[] { evt.EventId }); using var connection = OpenVerifierConnection(dataSource); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT AttemptCount, LastAttemptUtc FROM audit_forward_state WHERE EventId = $id;"; cmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); using var reader = cmd.ExecuteReader(); Assert.True(reader.Read()); Assert.Equal(1, reader.GetInt32(0)); // AttemptCount bumped 0 → 1 Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped } [Fact] public async Task MarkForwardedAsync_NonExistentId_NoThrow() { var (writer, _) = CreateWriter(nameof(MarkForwardedAsync_NonExistentId_NoThrow)); await using var _writer = writer; var phantomIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; await writer.MarkForwardedAsync(phantomIds); // No assertion needed: the call must complete without throwing. } [Fact] public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows() { var (writer, _) = CreateWriter(nameof(ReadForwardedAsync_Returns_Only_Forwarded_Rows)); await using var _w = writer; var forwarded = NewEvent(); var pending = NewEvent(); await writer.WriteAsync(forwarded); await writer.WriteAsync(pending); await writer.MarkForwardedAsync(new[] { forwarded.EventId }); var rows = await writer.ReadForwardedAsync(limit: 10); var row = Assert.Single(rows); Assert.Equal(forwarded.EventId, row.EventId); } // ----- M6 reconciliation pull surface ----- // [Fact] public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN() { var (writer, dataSource) = CreateWriter(nameof(ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN)); await using var _ = writer; var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); var evts = new[] { NewEvent(occurredAtUtc: baseTime.AddSeconds(5)), NewEvent(occurredAtUtc: baseTime.AddSeconds(1)), NewEvent(occurredAtUtc: baseTime.AddSeconds(3)), NewEvent(occurredAtUtc: baseTime.AddSeconds(2)), NewEvent(occurredAtUtc: baseTime.AddSeconds(4)), }; foreach (var e in evts) await writer.WriteAsync(e); // Flip half to Forwarded — they must still surface in the reconciliation pull // because central hasn't confirmed they were ingested yet. await writer.MarkForwardedAsync(new[] { evts[0].EventId, evts[2].EventId }); var rows = await writer.ReadPendingSinceAsync(sinceUtc: DateTime.MinValue, batchSize: 3); Assert.Equal(3, rows.Count); Assert.Equal(baseTime.AddSeconds(1), rows[0].OccurredAtUtc); Assert.Equal(baseTime.AddSeconds(2), rows[1].OccurredAtUtc); Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc); } [Fact] public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc() { var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc)); await using var _w = writer; var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); var old = NewEvent(occurredAtUtc: baseTime.AddSeconds(-30)); var newer1 = NewEvent(occurredAtUtc: baseTime.AddSeconds(10)); var newer2 = NewEvent(occurredAtUtc: baseTime.AddSeconds(20)); await writer.WriteAsync(old); await writer.WriteAsync(newer1); await writer.WriteAsync(newer2); var rows = await writer.ReadPendingSinceAsync(sinceUtc: baseTime, batchSize: 10); Assert.Equal(2, rows.Count); Assert.Contains(rows, r => r.EventId == newer1.EventId); Assert.Contains(rows, r => r.EventId == newer2.EventId); Assert.DoesNotContain(rows, r => r.EventId == old.EventId); } [Fact] public async Task ReadPendingSinceAsync_ExcludesReconciledRows() { var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_ExcludesReconciledRows)); await using var _w = writer; var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); var pending = NewEvent(occurredAtUtc: baseTime); var reconciled = NewEvent(occurredAtUtc: baseTime.AddSeconds(1)); await writer.WriteAsync(pending); await writer.WriteAsync(reconciled); await writer.MarkReconciledAsync(new[] { reconciled.EventId }); var rows = await writer.ReadPendingSinceAsync(sinceUtc: DateTime.MinValue, batchSize: 10); Assert.Single(rows); Assert.Equal(pending.EventId, rows[0].EventId); } [Fact] public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws() { var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_InvalidBatchSize_Throws)); await using var _w = writer; await Assert.ThrowsAsync( () => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: 0)); await Assert.ThrowsAsync( () => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3)); } [Fact] public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled() { var (writer, dataSource) = CreateWriter(nameof(MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled)); await using var _ = writer; var a = NewEvent(); var b = NewEvent(); var c = NewEvent(); await writer.WriteAsync(a); await writer.WriteAsync(b); await writer.WriteAsync(c); // b is currently Forwarded; a and c are Pending. await writer.MarkForwardedAsync(new[] { b.EventId }); await writer.MarkReconciledAsync(new[] { a.EventId, b.EventId, c.EventId }); var byState = ForwardStateCounts(dataSource); Assert.Equal(3, byState[AuditForwardState.Reconciled.ToString()]); Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString())); Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString())); } [Fact] public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched() { var (writer, dataSource) = CreateWriter(nameof(MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched)); await using var _ = writer; var a = NewEvent(); await writer.WriteAsync(a); await writer.MarkReconciledAsync(new[] { a.EventId }); // Re-call must not throw and must leave the single row Reconciled. await writer.MarkReconciledAsync(new[] { a.EventId }); Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId)); } [Fact] public async Task MarkReconciledAsync_NonExistentId_NoThrow() { var (writer, _) = CreateWriter(nameof(MarkReconciledAsync_NonExistentId_NoThrow)); await using var _w = writer; await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() }); // Completes without throwing. } /// /// Fix 2 / M1 state guard: /// must NOT demote a row back to /// . When a batch contains both a /// Pending ID and an already-Reconciled ID: /// /// the Pending row transitions to Forwarded (normal path) /// the Reconciled row stays Reconciled (AttemptCount unchanged) /// /// This mirrors the idempotency guard already present on /// . /// [Fact] public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions() { var (writer, dataSource) = CreateWriter( nameof(MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions)); await using var _ = writer; var pending = NewEvent(); var reconciled = NewEvent(); await writer.WriteAsync(pending); await writer.WriteAsync(reconciled); // Advance reconciled through Forwarded → Reconciled so its AttemptCount = 1. await writer.MarkForwardedAsync(new[] { reconciled.EventId }); await writer.MarkReconciledAsync(new[] { reconciled.EventId }); // Verify the reconciled row's AttemptCount is 1 before the test call. using var conn = OpenVerifierConnection(dataSource); long reconciledAttemptBefore; using (var cmd = conn.CreateCommand()) { cmd.CommandText = "SELECT AttemptCount FROM audit_forward_state WHERE EventId = $id;"; cmd.Parameters.AddWithValue("$id", reconciled.EventId.ToString()); reconciledAttemptBefore = Convert.ToInt64(cmd.ExecuteScalar()); } Assert.Equal(1L, reconciledAttemptBefore); // Now call MarkForwardedAsync with BOTH IDs in the same batch. await writer.MarkForwardedAsync(new[] { pending.EventId, reconciled.EventId }); // The Pending row must have transitioned to Forwarded. Assert.Equal( AuditForwardState.Forwarded.ToString(), ReadForwardState(dataSource, pending.EventId)); // The Reconciled row must remain Reconciled — the state guard must have // excluded it from the UPDATE. Assert.Equal( AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, reconciled.EventId)); // AttemptCount on the Reconciled row must be unchanged (still 1, not 2). using (var cmd = conn.CreateCommand()) { cmd.CommandText = "SELECT AttemptCount FROM audit_forward_state WHERE EventId = $id;"; cmd.Parameters.AddWithValue("$id", reconciled.EventId.ToString()); var attemptAfter = Convert.ToInt64(cmd.ExecuteScalar()); Assert.Equal(reconciledAttemptBefore, attemptAfter); } } // ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- // [Fact] public async Task WriteAsync_NonNullExecutionId_RoundTrips() { var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTrips)); await using var _w = writer; var executionId = Guid.NewGuid(); var evt = NewEvent(executionId: executionId); await writer.WriteAsync(evt); var rows = await writer.ReadPendingAsync(limit: 10); var row = Assert.Single(rows); Assert.Equal(executionId, row.AsRow().ExecutionId); } [Fact] public async Task WriteAsync_NullExecutionId_RoundTripsAsNull() { var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull)); await using var _w = writer; var evt = NewEvent(); // executionId defaults to null await writer.WriteAsync(evt); var rows = await writer.ReadPendingAsync(limit: 10); var row = Assert.Single(rows); Assert.Null(row.AsRow().ExecutionId); } // ----- SourceNode stamping (Tasks 11/12) ----- // [Fact] public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() { var (writer, _) = CreateWriter( nameof(WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone), nodeIdentity: new FakeNodeIdentityProvider("node-a")); await using var _w = writer; var evt = NewEvent(); Assert.Null(evt.SourceNode); // sanity check — fresh event has no SourceNode await writer.WriteAsync(evt); var rows = await writer.ReadPendingAsync(limit: 10); var row = Assert.Single(rows); Assert.Equal("node-a", row.SourceNode); } [Fact] public async Task WriteAsync_PreservesCallerProvidedSourceNode() { var (writer, _) = CreateWriter( nameof(WriteAsync_PreservesCallerProvidedSourceNode), nodeIdentity: new FakeNodeIdentityProvider("node-a")); await using var _w = writer; // Reconciled rows from another node arrive with their origin's // SourceNode already populated; the writer must preserve it. var evt = NewEvent(sourceNode: "node-z"); await writer.WriteAsync(evt); var rows = await writer.ReadPendingAsync(limit: 10); var row = Assert.Single(rows); Assert.Equal("node-z", row.SourceNode); } [Fact] public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() { var (writer, _) = CreateWriter( nameof(WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull), nodeIdentity: new FakeNodeIdentityProvider(nodeName: null)); await using var _w = writer; var evt = NewEvent(); await writer.WriteAsync(evt); var rows = await writer.ReadPendingAsync(limit: 10); var row = Assert.Single(rows); Assert.Null(row.SourceNode); } // ----- C4 hardening: safe enum-parse in MapRow ----- // /// /// C4 hardening (Task 2.5): a row whose stored Outcome column holds an /// unknown/renamed enum string must NOT fault the read path; it degrades /// gracefully to (the safe /// fallback). The read is /// exercised via the public /// surface which calls the private MapRow. /// [Fact] public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback() { var (writer, dataSource) = CreateWriter( nameof(ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback)); await using var _ = writer; var evt = NewEvent(); await writer.WriteAsync(evt); // Tamper: overwrite the canonical Outcome column with a string that is not // a declared AuditOutcome member name. using (var conn = OpenVerifierConnection(dataSource)) using (var cmd = conn.CreateCommand()) { cmd.CommandText = "UPDATE audit_event SET Outcome = 'RenamedOutcome99' WHERE EventId = $id;"; cmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); cmd.ExecuteNonQuery(); } // Must not throw (a raw Enum.Parse would throw ArgumentException). var rows = await writer.ReadPendingAsync(limit: 10); var row = Assert.Single(rows); Assert.Equal(AuditOutcome.Success, row.Outcome); } }