using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter; using ZB.MOM.WW.ScadaBridge.StoreAndForward; using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// Every script-initiated /// Notify.To("list").Send(...) emits exactly one /// Notification/NotifySend audit event via the wrapper inside /// . The audit emission is /// best-effort: a thrown must never /// abort the script's Send — the original NotificationId must /// still flow back to the caller and the underlying S&F enqueue must still /// have happened. /// public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable { /// /// In-memory that records every event passed to /// . Optionally configurable to throw, simulating a /// catastrophic audit-writer failure that the wrapper must swallow per /// alog.md §7. /// private sealed class CapturingAuditWriter : IAuditWriter { // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. /// Gets the recorded audit rows. public List Events { get; } = new(); /// Gets or sets an exception to throw on write. public Exception? ThrowOnWrite { get; set; } /// public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { if (ThrowOnWrite != null) { return Task.FromException(ThrowOnWrite); } Events.Add(evt.AsRow()); return Task.CompletedTask; } } private const string SiteId = "site-7"; private const string InstanceName = "Plant.Pump3"; private const string SourceScript = "ScriptActor:CheckPressure"; private const string ListName = "Operators"; private const string Subject = "Pump alarm"; private const string Body = "Pump 3 tripped"; /// /// A fixed per-execution id so the NotifySend test can /// assert against a known value. /// private static readonly Guid TestExecutionId = Guid.NewGuid(); private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _saf; /// Initializes the test class with in-memory storage and a recording audit writer. public NotifySendAuditEmissionTests() { // LocalDb has no in-memory mode, so the store runs over a real temp file. _localDb = TestLocalDb.CreateTemp("NotifySendAudit"); _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); var options = new StoreAndForwardOptions { DefaultRetryInterval = TimeSpan.Zero, DefaultMaxRetries = 3, RetryTimerInterval = TimeSpan.FromMinutes(10) }; _saf = new StoreAndForwardService(_storage, options, NullLogger.Instance); } /// Prepares the test fixture before the first test runs. /// A task that represents the asynchronous operation. public async Task InitializeAsync() => await _storage.InitializeAsync(); /// Does nothing; cleanup happens in Dispose. /// A task that represents the asynchronous operation. public Task DisposeAsync() => Task.CompletedTask; /// protected override void Dispose(bool disposing) { if (disposing) { // The master connection anchors the WAL — dispose before deleting. _localDb.Dispose(); TestLocalDb.DeleteFiles(_localDb.Path); } base.Dispose(disposing); } private ScriptRuntimeContext.NotifyHelper CreateHelper( IAuditWriter? auditWriter, string? sourceScript = SourceScript, Guid? parentExecutionId = null) { // siteCommunicationActor is unused by Send — pass a probe so the helper // is fully constructed. var probe = CreateTestProbe(); return new ScriptRuntimeContext.NotifyHelper( _saf, probe.Ref, SiteId, InstanceName, sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance, TestExecutionId, auditWriter, parentExecutionId: parentExecutionId); } /// Verifies that a successful send emits one NotifySend event with Submitted status. /// A task that represents the asynchronous operation. [Fact] public async Task Send_Success_EmitsOneEvent_KindNotifySend_StatusSubmitted() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); var notificationId = await notify.To(ListName).Send(Subject, Body); Assert.False(string.IsNullOrEmpty(notificationId)); Assert.Single(writer.Events); var evt = writer.Events[0]; Assert.Equal(AuditChannel.Notification, evt.Channel); Assert.Equal(AuditKind.NotifySend, evt.Kind); Assert.Equal(AuditStatus.Submitted, evt.Status); // C3: ForwardState is no longer a canonical field (site-storage-only). Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind); Assert.NotEqual(Guid.Empty, evt.EventId); Assert.False(evt.PayloadTruncated); Assert.Null(evt.DurationMs); Assert.Null(evt.HttpStatus); Assert.Null(evt.ErrorMessage); Assert.Null(evt.ErrorDetail); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); } /// Verifies that the audit target is populated with the list name. /// A task that represents the asynchronous operation. [Fact] public async Task Send_PopulatesTarget_AsListName() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); await notify.To(ListName).Send(Subject, Body); var evt = writer.Events[0]; Assert.Equal(ListName, evt.Target); } /// Verifies that the request summary is populated as the subject and body JSON. /// A task that represents the asynchronous operation. [Fact] public async Task Send_PopulatesRequestSummary_AsSubjectBodyJson() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); await notify.To(ListName).Send(Subject, Body); var evt = writer.Events[0]; Assert.NotNull(evt.RequestSummary); // Round-trip the JSON to assert the exact shape, not raw text — the // contract is "JSON of {subject, body}", which downstream redaction // can reshape; the row captures it verbatim. using var doc = JsonDocument.Parse(evt.RequestSummary!); var root = doc.RootElement; Assert.Equal(JsonValueKind.Object, root.ValueKind); Assert.Equal(Subject, root.GetProperty("subject").GetString()); Assert.Equal(Body, root.GetProperty("body").GetString()); } /// Verifies that a throwing audit writer does not prevent the original send from returning. /// A task that represents the asynchronous operation. [Fact] public async Task Send_AuditWriter_Throws_OriginalSendStillReturns() { var writer = new CapturingAuditWriter { ThrowOnWrite = new InvalidOperationException("audit writer down") }; var notify = CreateHelper(writer); // The Send call must NOT bubble the audit-writer failure: the script // contract is that the notification is buffered and the id is returned // even when the audit pipeline is sick. var notificationId = await notify.To(ListName).Send(Subject, Body); Assert.False(string.IsNullOrEmpty(notificationId)); // And the underlying S&F enqueue must still have happened — audit is // purely additive, never aborts the user-facing action. var buffered = await _saf.GetMessageByIdAsync(notificationId); Assert.NotNull(buffered); Assert.Equal(notificationId, buffered!.Id); Assert.Empty(writer.Events); } /// Verifies that the provenance fields are populated from the script context. /// A task that represents the asynchronous operation. [Fact] public async Task Send_Provenance_PopulatedFromContext() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); await notify.To(ListName).Send(Subject, Body); var evt = writer.Events[0]; Assert.Equal(SiteId, evt.SourceSiteId); Assert.Equal(InstanceName, evt.SourceInstanceId); Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); } /// Verifies that the notification id is parsed as the correlation id. /// A task that represents the asynchronous operation. [Fact] public async Task Send_NotificationIdParsed_AsCorrelationId() { var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); var notificationId = await notify.To(ListName).Send(Subject, Body); // NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char // hex form, which Guid.TryParse accepts. The audit row's CorrelationId // must round-trip back to the same Guid value (the per-operation // lifecycle id). ExecutionId carries the per-execution id instead. Assert.True(Guid.TryParse(notificationId, out var expected), $"NotificationId '{notificationId}' should be a parseable Guid"); var evt = writer.Events[0]; Assert.NotNull(evt.CorrelationId); Assert.Equal(expected, evt.CorrelationId); Assert.Equal(TestExecutionId, evt.ExecutionId); // ParentExecutionId: null for a non-routed run. Assert.Null(evt.ParentExecutionId); } /// Verifies that a routed run stamps the parent execution id on the NotifySend row. /// A task that represents the asynchronous operation. [Fact] public async Task Send_RoutedRun_StampsParentExecutionId_OnNotifySendRow() { // ParentExecutionId: an inbound-API-routed run // carries the spawning execution's id; the NotifySend row must stamp // it in ParentExecutionId alongside its own ExecutionId. var parentExecutionId = Guid.NewGuid(); var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer, parentExecutionId: parentExecutionId); await notify.To(ListName).Send(Subject, Body); var evt = Assert.Single(writer.Events); Assert.Equal(parentExecutionId, evt.ParentExecutionId); Assert.Equal(TestExecutionId, evt.ExecutionId); } /// Verifies that a non-routed run leaves the parent execution id null. /// A task that represents the asynchronous operation. [Fact] public async Task Send_NonRoutedRun_ParentExecutionIdIsNull() { // A normal (tag/timer) run is not routed — the NotifySend row's // ParentExecutionId stays null. var writer = new CapturingAuditWriter(); var notify = CreateHelper(writer); await notify.To(ListName).Send(Subject, Body); var evt = Assert.Single(writer.Events); Assert.Null(evt.ParentExecutionId); } /// Verifies that sending without an audit writer still returns a notification id and enqueues the message. /// A task that represents the asynchronous operation. [Fact] public async Task Send_WithoutAuditWriter_StillReturnsNotificationId_AndEnqueues() { // Audit is opt-in: a null writer must // degrade to a no-op audit path so tests / minimal hosts that don't // wire AddAuditLog still work. var notify = CreateHelper(auditWriter: null); var notificationId = await notify.To(ListName).Send(Subject, Body); Assert.False(string.IsNullOrEmpty(notificationId)); var buffered = await _saf.GetMessageByIdAsync(notificationId); Assert.NotNull(buffered); } }