using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Communication; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests; /// /// Gitea #73: in-memory coverage of 's error, retry and /// state-transition branches that the existing suites leave untested — or test only behind /// the MSSQL fixture, which skips without a database. Covers: a failed page write holding the /// reconciliation cursor so the next tick re-pulls the window; the pinned latch clearing (and /// publishing only on transitions); an enumerator fault not wedging the single-flight guard; /// the per-tick page ceiling; the purge single-flight guard; the read-side and ingest fault /// mapping; and the Discard / audit-sink branches of the central→site relay. /// public class SiteCallAuditFailurePathTests : TestKit { private static SiteCall Row(string site, DateTime updatedAtUtc, string channel = "ApiOutbound") => new() { TrackedOperationId = TrackedOperationId.New(), Channel = channel, Target = "ERP.GetOrder", SourceSite = site, SourceNode = "node-a", Status = "Parked", RetryCount = 1, CreatedAtUtc = updatedAtUtc, UpdatedAtUtc = updatedAtUtc, IngestedAtUtc = updatedAtUtc, }; private static SiteCallAuditOptions FastTicks(int batchSize = 500) => new() { ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(100), PurgeIntervalOverride = TimeSpan.FromMinutes(10), ReconciliationBatchSize = batchSize, RelayTimeout = TimeSpan.FromMilliseconds(500), }; /// Repository whose every member can be scripted; unscripted members are inert. private sealed class ScriptableRepo : ISiteCallAuditRepository { private int _upsertManyCalls; /// Gets the rows written by successful batched upserts. public List Written { get; } = new(); /// Number of leading UpsertManyAsync calls that throw. public int FailFirstUpsertMany { get; set; } /// Optional override for single-row upserts. public Func? OnUpsert { get; set; } /// Optional override for GetAsync. public Func>? OnGet { get; set; } /// Optional override for QueryAsync. public Func>>? OnQuery { get; set; } /// Optional override for PurgeTerminalAsync. public Func>? OnPurge { get; set; } /// When set, every KPI computation throws it. public Exception? KpiFault { get; set; } /// public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => OnUpsert?.Invoke(siteCall) ?? Task.CompletedTask; /// public Task UpsertManyAsync(IReadOnlyList siteCalls, CancellationToken ct = default) { if (Interlocked.Increment(ref _upsertManyCalls) <= FailFirstUpsertMany) throw new InvalidOperationException("simulated page write failure"); lock (Written) Written.AddRange(siteCalls); return Task.CompletedTask; } /// public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => OnGet?.Invoke(id) ?? Task.FromResult(null); /// public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => OnQuery?.Invoke(filter, paging) ?? Task.FromResult>(Array.Empty()); /// public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => OnPurge?.Invoke(olderThanUtc) ?? Task.FromResult(0); /// public Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => KpiFault is null ? Task.FromResult(new SiteCallKpiSnapshot(1, 2, 3, 4, null, 5)) : Task.FromException(KpiFault); /// public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => KpiFault is null ? Task.FromResult>(Array.Empty()) : Task.FromException>(KpiFault); /// public Task> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => KpiFault is null ? Task.FromResult>(Array.Empty()) : Task.FromException>(KpiFault); } /// Enumerator that throws on its first N calls, counting every call. private sealed class FlakyEnumerator(int failFirst, params SiteEntry[] sites) : ISiteEnumerator { private int _calls; /// Gets the number of enumerations performed so far. public int Calls => Volatile.Read(ref _calls); /// public Task> EnumerateAsync(CancellationToken ct = default) => Interlocked.Increment(ref _calls) <= failFirst ? throw new InvalidOperationException("simulated config DB outage") : Task.FromResult>(sites); } /// /// Keyset-honouring site over a fixed row set that can be switched into "legacy" mode, where /// it ignores the cursor and always reports a saturated page. /// private sealed class SwitchablePullClient(IEnumerable rows) : IPullSiteCallsClient { private readonly List _rows = rows .OrderBy(r => r.UpdatedAtUtc) .ThenBy(r => r.TrackedOperationId.ToString(), StringComparer.Ordinal) .ToList(); /// Gets every pull recorded, with the enumerator tick it ran under. public List<(DateTime Since, string? AfterId)> Calls { get; } = new(); /// Gets or sets whether the site ignores after_id (pre-keyset). public volatile bool Legacy; /// public Task PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { lock (Calls) Calls.Add((sinceUtc, afterId)); if (Legacy) return Task.FromResult(new PullSiteCallsResponse(_rows.Take(batchSize).ToList(), MoreAvailable: true)); var greater = _rows.Where(r => r.UpdatedAtUtc > sinceUtc || (r.UpdatedAtUtc == sinceUtc && string.CompareOrdinal(r.TrackedOperationId.ToString(), afterId) > 0)) .ToList(); var page = greater.Take(batchSize).ToList(); return Task.FromResult(new PullSiteCallsResponse(page, MoreAvailable: greater.Count > page.Count)); } } /// A site with an endless backlog: every pull returns one newer row and MoreAvailable. private sealed class EndlessBacklogPullClient(FlakyEnumerator ticks) : IPullSiteCallsClient { private static readonly DateTime Start = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private int _n; /// Gets every pull with the enumeration (tick) number it belongs to. public List<(int Tick, DateTime Since, DateTime Returned)> Calls { get; } = new(); /// public Task PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { var row = Row(siteId, Start.AddSeconds(Interlocked.Increment(ref _n))); lock (Calls) Calls.Add((ticks.Calls, sinceUtc, row.UpdatedAtUtc)); return Task.FromResult(new PullSiteCallsResponse(new[] { row }, MoreAvailable: true)); } } /// No-op pull client that only arms the reconciliation scheduler. private sealed class NoOpPullClient : IPullSiteCallsClient { /// public Task PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) => Task.FromResult(new PullSiteCallsResponse(Array.Empty(), MoreAvailable: false)); } /// Central audit sink that throws on every write. private sealed class ThrowingAuditWriter : ICentralAuditWriter { private int _calls; /// Gets the number of write attempts. public int Calls => Volatile.Read(ref _calls); /// public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default) { Interlocked.Increment(ref _calls); throw new InvalidOperationException("audit store down"); } } /// Central audit sink that records every row. private sealed class RecordingAuditWriter : ICentralAuditWriter { /// Gets the recorded rows. public List Events { get; } = new(); /// public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default) { lock (Events) Events.Add(evt.AsRow()); return Task.CompletedTask; } } private IActorRef CreateActor( ISiteCallAuditRepository repo, SiteCallAuditOptions options, ISiteEnumerator? sites = null, IPullSiteCallsClient? client = null, ICentralAuditWriter? auditWriter = null, bool serialize = true) { var provider = TestSupport.StubRepositoryProvider.Build(repo, sites, client, auditWriter, serialize); return Sys.ActorOf(Props.Create(() => new SiteCallAuditActor( provider, options, NullLogger.Instance))); } private void Stop(IActorRef actor) { Watch(actor); Sys.Stop(actor); ExpectTerminated(actor, TimeSpan.FromSeconds(3)); } // ── Reconciliation: retry, latch and guard transitions ── /// /// A page whose batched upsert fails must NOT advance the site's cursor: the next tick /// re-pulls the same window from the old cursor, and once the write succeeds the cursor /// moves past it. Advancing first would silently drop the failed page from the mirror. /// [Fact] public void Reconciliation_PageWriteFails_CursorHeld_NextTickRePullsSameWindow() { var t1 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc); var rows = new[] { Row("siteA", t1), Row("siteA", t1.AddMinutes(1)) }; var client = new SwitchablePullClient(rows); var repo = new ScriptableRepo { FailFirstUpsertMany = 1 }; var actor = CreateActor(repo, FastTicks(), new FlakyEnumerator(0, new SiteEntry("siteA", "http://a")), client); try { AwaitAssert(() => { lock (client.Calls) Assert.True(client.Calls.Count >= 3, $"pulls: {client.Calls.Count}"); }, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(50)); List<(DateTime Since, string? AfterId)> calls; lock (client.Calls) calls = client.Calls.ToList(); Assert.Equal(DateTime.MinValue, calls[0].Since); // failed write Assert.Equal(DateTime.MinValue, calls[1].Since); // same window re-pulled Assert.Equal(t1.AddMinutes(1), calls[2].Since); // advanced only after the write landed lock (repo.Written) Assert.Equal(2, repo.Written.Count); } finally { Stop(actor); } } /// /// The pinned latch is transition-only: a site that stays pinned across many ticks publishes /// Pinned=true once, and when it starts honouring the keyset again it publishes Pinned=false /// exactly once. /// [Fact] public void Reconciliation_PinnedSiteResumesProgress_PublishesEachTransitionOnce() { var ts = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc); var rows = new[] { Row("siteP", ts), Row("siteP", ts), Row("siteP", ts) }; var client = new SwitchablePullClient(rows) { Legacy = true }; var probe = CreateTestProbe(); Sys.EventStream.Subscribe(probe.Ref, typeof(SiteCallReconciliationPinnedChanged)); var actor = CreateActor(new ScriptableRepo(), FastTicks(batchSize: 2), new FlakyEnumerator(0, new SiteEntry("siteP", "http://p")), client); try { Assert.True(probe.ExpectMsg(TimeSpan.FromSeconds(5)).Pinned); // Several more pinned ticks: no repeat publication. var pullsAtPin = client.Calls.Count; AwaitCondition(() => client.Calls.Count >= pullsAtPin + 3, TimeSpan.FromSeconds(5)); probe.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); client.Legacy = false; // the site is upgraded and honours after_id var unpinned = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("siteP", unpinned.SiteId); Assert.False(unpinned.Pinned); probe.ExpectNoMsg(TimeSpan.FromMilliseconds(400)); } finally { Sys.EventStream.Unsubscribe(probe.Ref); Stop(actor); } } /// /// A site-enumeration fault skips that tick but lowers the single-flight guard, so the next /// tick reconciles normally — one config-DB blip must not wedge the self-heal for good. /// [Fact] public void Reconciliation_EnumeratorThrows_TickSkipped_NextTickReconciles() { var row = Row("siteA", new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)); var sites = new FlakyEnumerator(2, new SiteEntry("siteA", "http://a")); var client = new SwitchablePullClient(new[] { row }); var repo = new ScriptableRepo(); var actor = CreateActor(repo, FastTicks(), sites, client); try { AwaitAssert(() => { lock (repo.Written) Assert.Contains(repo.Written, r => r.TrackedOperationId == row.TrackedOperationId); }, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(50)); Assert.True(sites.Calls >= 3); // No pull happened during the two failed enumerations. lock (client.Calls) Assert.Equal(DateTime.MinValue, client.Calls[0].Since); } finally { Stop(actor); } } /// /// A site that always reports more rows is drained at most 50 pages per tick (bounded, not /// pinned — the cursor advances each page), and the next tick resumes from the advanced /// cursor rather than starting over. /// [Fact] public void Reconciliation_EndlessBacklog_BoundedPerTick_NextTickResumesFromAdvancedCursor() { var sites = new FlakyEnumerator(0, new SiteEntry("siteB", "http://b")); var client = new EndlessBacklogPullClient(sites); var probe = CreateTestProbe(); Sys.EventStream.Subscribe(probe.Ref, typeof(SiteCallReconciliationPinnedChanged)); var actor = CreateActor(new ScriptableRepo(), FastTicks(batchSize: 1), sites, client); try { AwaitAssert(() => { lock (client.Calls) Assert.Contains(client.Calls, c => c.Tick >= 2); }, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(50)); List<(int Tick, DateTime Since, DateTime Returned)> calls; lock (client.Calls) calls = client.Calls.ToList(); var firstTick = calls.Where(c => c.Tick == 1).ToList(); var secondTickFirstPull = calls.First(c => c.Tick == 2); Assert.Equal(50, firstTick.Count); Assert.Equal(firstTick[^1].Returned, secondTickFirstPull.Since); probe.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); // progressing, so never pinned } finally { Sys.EventStream.Unsubscribe(probe.Ref); Stop(actor); } } // ── Purge single-flight ── /// /// A purge that outlives its interval is not stacked: further ticks are dropped while it is /// in flight, and the next purge runs once it completes. /// [Fact] public void Purge_InFlight_FurtherTicksDropped_UntilItCompletes() { var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var calls = 0; var repo = new ScriptableRepo { OnPurge = _ => Interlocked.Increment(ref calls) == 1 ? release.Task : Task.FromResult(0), }; var options = FastTicks(); options.ReconciliationIntervalOverride = TimeSpan.FromMinutes(10); options.PurgeIntervalOverride = TimeSpan.FromMilliseconds(50); var actor = CreateActor(repo, options, new FlakyEnumerator(0), new NoOpPullClient(), serialize: false); try { AwaitCondition(() => Volatile.Read(ref calls) == 1, TimeSpan.FromSeconds(3)); // ~10 purge intervals elapse while the first purge is blocked. ExpectNoMsg(TimeSpan.FromMilliseconds(500)); Assert.Equal(1, Volatile.Read(ref calls)); release.SetResult(3); AwaitCondition(() => Volatile.Read(ref calls) >= 2, TimeSpan.FromSeconds(3)); } finally { release.TrySetResult(0); Stop(actor); } } // ── Ingest and read-side fault mapping ── /// /// An ingest write fault replies Accepted=false (the telemetry sender keeps the row for a /// later push) and the singleton stays alive to accept the next upsert. /// [Fact] public void Upsert_RepositoryThrows_RepliesNotAccepted_ThenAcceptsNext() { var calls = 0; var repo = new ScriptableRepo { OnUpsert = _ => Interlocked.Increment(ref calls) == 1 ? throw new TimeoutException("SQL timeout") : Task.CompletedTask, }; var actor = CreateActor(repo, FastTicks()); var first = Row("siteA", DateTime.UtcNow); var second = Row("siteA", DateTime.UtcNow); actor.Tell(new UpsertSiteCallCommand(first)); var failed = ExpectMsg(); Assert.Equal(first.TrackedOperationId, failed.TrackedOperationId); Assert.False(failed.Accepted); actor.Tell(new UpsertSiteCallCommand(second)); var ok = ExpectMsg(); Assert.Equal(second.TrackedOperationId, ok.TrackedOperationId); Assert.True(ok.Accepted); } /// /// Every read handler maps a repository fault onto Success=false with the root-cause message /// and an empty payload — never a dead letter or a crashed singleton — and keeps serving. /// [Fact] public void ReadHandlers_RepositoryThrows_ReplyFailureWithRootCause_AndStayAlive() { var fault = new InvalidOperationException("wrapper", new TimeoutException("SQL timeout")); var repo = new ScriptableRepo { KpiFault = fault, OnGet = _ => Task.FromException(fault), OnQuery = (_, _) => Task.FromException>(fault), }; var actor = CreateActor(repo, FastTicks()); actor.Tell(new SiteCallDetailRequest("d", Guid.NewGuid())); var detail = ExpectMsg(); Assert.False(detail.Success); Assert.Equal("SQL timeout", detail.ErrorMessage); Assert.Null(detail.Detail); actor.Tell(new SiteCallKpiRequest("k")); var kpi = ExpectMsg(); Assert.False(kpi.Success); Assert.Equal("SQL timeout", kpi.ErrorMessage); Assert.Equal(0, kpi.BufferedCount + kpi.ParkedCount + kpi.StuckCount); actor.Tell(new PerSiteSiteCallKpiRequest("s")); var perSite = ExpectMsg(); Assert.False(perSite.Success); Assert.Equal("SQL timeout", perSite.ErrorMessage); Assert.Empty(perSite.Sites); actor.Tell(new PerNodeSiteCallKpiRequest("n")); var perNode = ExpectMsg(); Assert.False(perNode.Success); Assert.Equal("SQL timeout", perNode.ErrorMessage); Assert.Empty(perNode.Nodes); actor.Tell(new SiteCallQueryRequest("q", null, null, null, null, false, null, null, null, null, 50)); var query = ExpectMsg(); Assert.False(query.Success); Assert.Equal("SQL timeout", query.ErrorMessage); Assert.Empty(query.SiteCalls); Assert.Null(query.NextAfterId); // Still alive: a healthy KPI read after the faults succeeds. repo.KpiFault = null; actor.Tell(new SiteCallKpiRequest("k2")); Assert.True(ExpectMsg().Success); } /// /// The query page size is clamped to [1, 200] before it reaches SQL (an unbounded page from a /// crafted request would materialise the whole table), and an empty page carries no cursor. /// [Theory] [InlineData(0, 1)] [InlineData(-5, 1)] [InlineData(10_000, 200)] [InlineData(37, 37)] public void Query_PageSizeClamped_EmptyPageHasNoCursor(int requested, int expected) { var seen = new List(); var repo = new ScriptableRepo { OnQuery = (_, paging) => { lock (seen) seen.Add(paging.PageSize); return Task.FromResult>(Array.Empty()); }, }; var actor = CreateActor(repo, FastTicks()); actor.Tell(new SiteCallQueryRequest("q", null, null, null, null, false, null, null, null, null, requested)); var response = ExpectMsg(); Assert.True(response.Success); lock (seen) Assert.Equal(new[] { expected }, seen); Assert.Null(response.NextAfterCreatedAtUtc); Assert.Null(response.NextAfterId); } // ── Relay: Discard branches and the best-effort audit sink ── /// /// Discard maps the site's ack the same way Retry does: a clean no-op is NotParked, an ack /// with an error is OperationFailed carrying the site's message verbatim — the site was /// reached either way — and neither emits an operator audit row. /// /// The error the site's ack carries, or null for a clean no-op. /// The outcome the relay must report. [Theory] [InlineData(null, SiteCallRelayOutcome.NotParked)] [InlineData("S&F buffer locked", SiteCallRelayOutcome.OperationFailed)] public void DiscardSiteCall_SiteDidNotApply_MapsOutcome_NoAuditRow(string? siteError, SiteCallRelayOutcome expected) { var id = Guid.NewGuid(); var writer = new RecordingAuditWriter(); var repo = new ScriptableRepo { OnGet = _ => Task.FromResult(Row("site-north", DateTime.UtcNow)) }; var central = CreateTestProbe(); var actor = CreateActor(repo, FastTicks(), auditWriter: writer); actor.Tell(new RegisterCentralCommunication(central.Ref)); actor.Tell(new DiscardSiteCallRequest("corr", id, "site-north", RequestedBy: "jdoe")); var relay = (DiscardParkedOperation)central.ExpectMsg().Message; central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: false, siteError)); var response = ExpectMsg(); Assert.Equal(expected, response.Outcome); Assert.False(response.Success); Assert.True(response.SiteReachable); if (siteError is not null) Assert.Equal(siteError, response.ErrorMessage); else Assert.Contains("no longer parked", response.ErrorMessage); ExpectNoMsg(TimeSpan.FromMilliseconds(200)); lock (writer.Events) Assert.Empty(writer.Events); } /// A Discard before any central→site transport is registered is reported unreachable. [Fact] public void DiscardSiteCall_BeforeCentralCommunicationRegistered_ReportsSiteUnreachable() { var actor = CreateActor(new ScriptableRepo(), FastTicks()); actor.Tell(new DiscardSiteCallRequest("corr", Guid.NewGuid(), "site-north")); var response = ExpectMsg(); Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome); Assert.False(response.SiteReachable); Assert.False(response.Success); } /// /// The operator audit row is best-effort: a throwing audit sink must not turn an Applied /// relay into a failure — the site already acted, and reporting otherwise would invite a /// duplicate retry. /// [Fact] public void RetrySiteCall_AuditSinkThrows_RelayStillReportsApplied() { var id = Guid.NewGuid(); var writer = new ThrowingAuditWriter(); var repo = new ScriptableRepo { OnGet = _ => Task.FromResult(Row("site-north", DateTime.UtcNow)) }; var central = CreateTestProbe(); var actor = CreateActor(repo, FastTicks(), auditWriter: writer); actor.Tell(new RegisterCentralCommunication(central.Ref)); actor.Tell(new RetrySiteCallRequest("corr", id, "site-north", RequestedBy: "jdoe")); var relay = (RetryParkedOperation)central.ExpectMsg().Message; central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true)); var response = ExpectMsg(); Assert.Equal(SiteCallRelayOutcome.Applied, response.Outcome); Assert.True(response.Success); Assert.Equal(1, writer.Calls); // the write WAS attempted } /// /// When the mirror row is missing (telemetry not ingested yet) or carries an unknown channel, /// the operator audit row is still written — falling back to ApiOutbound — so who acted is /// never lost. /// /// The mirror row's channel, or null for no mirror row. [Theory] [InlineData(null)] [InlineData("NotAChannel")] public void RetrySiteCall_MirrorRowMissingOrUnknownChannel_AuditFallsBackToApiOutbound(string? mirrorChannel) { var id = Guid.NewGuid(); var writer = new RecordingAuditWriter(); var repo = new ScriptableRepo { OnGet = _ => Task.FromResult( mirrorChannel is null ? null : Row("site-north", DateTime.UtcNow, mirrorChannel)), }; var central = CreateTestProbe(); var actor = CreateActor(repo, FastTicks(), auditWriter: writer); actor.Tell(new RegisterCentralCommunication(central.Ref)); actor.Tell(new RetrySiteCallRequest("corr", id, "site-north", RequestedBy: "jdoe")); var relay = (RetryParkedOperation)central.ExpectMsg().Message; central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true)); Assert.True(ExpectMsg().Success); AwaitAssert(() => { lock (writer.Events) { var evt = Assert.Single(writer.Events); Assert.Equal(AuditChannel.ApiOutbound, evt.Channel); Assert.Equal(AuditStatus.Submitted, evt.Status); Assert.Equal("jdoe", evt.Actor); Assert.Equal(id, evt.CorrelationId); } }); } }