using System.Collections.Concurrent; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; 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; using ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.AuditLog; /// /// End-to-end integration test for the Audit Log (#23) site→central push path /// introduced by the "real ClusterClient-based site audit push client" follow-up. /// /// /// /// Exercises the full production chain in one actor system: the real /// site SQLite hot-path, the real /// drain loop, the real /// , the real /// forward, the real /// routing, and the real /// AuditLogIngestActor ingest — only the cross-cluster gRPC transport itself is /// substituted by an in-process that Tells the central /// actor exactly as the real GrpcCentralTransport would (a multi-node cluster is out of /// scope for an in-process test). /// /// /// The central audit store is an in-memory — /// the production AuditLogRepository emits SQL Server-specific T-SQL and /// needs an MSSQL container, which this test deliberately avoids. The test /// asserts both ends of the contract: a central AuditLog row appears AND /// the site SQLite row flips from to /// . /// /// public class SiteAuditPushFlowTests : TestKit { /// /// In-process stand-in for the site→central gRPC transport (GrpcCentralTransport): /// each site→central send is Tell'd straight to the central actor, preserving the reply-to so /// the central reply routes back to the site's Ask. A real transport does exactly this across /// the cluster boundary; the in-process bridge keeps the test free of a multi-node/gRPC setup. /// private sealed class BridgeCentralTransport : ICentralTransport { private readonly IActorRef _central; public BridgeCentralTransport(IActorRef central) => _central = central; public void SubmitNotification(NotificationSubmit message, IActorRef replyTo) => _central.Tell(message, replyTo); public void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo) => _central.Tell(message, replyTo); public void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo) => _central.Tell(message, replyTo); public void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo) => _central.Tell(message, replyTo); public void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo) => _central.Tell(message, replyTo); public void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo) => _central.Tell(message, replyTo); public void SendHeartbeat(HeartbeatMessage message, IActorRef self) => _central.Tell(message, self); } /// /// Thread-safe in-memory . Only /// is exercised by the ingest path; the /// rest throw because they are not reachable from this test. /// private sealed class InMemoryAuditLogRepository : IAuditLogRepository { private readonly ConcurrentDictionary _rows = new(); public IReadOnlyCollection Rows => _rows.Values.ToList(); public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(evt); // First-write-wins idempotency, mirroring the production repository. _rows.TryAdd(evt.EventId, evt); return Task.CompletedTask; } public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => throw new NotSupportedException(); public Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => throw new NotSupportedException(); public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) => throw new NotSupportedException(); public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => throw new NotSupportedException(); public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => throw new NotSupportedException(); public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default) => throw new NotSupportedException(); public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => throw new NotSupportedException(); } // C3 (Task 2.5): canonical record via the shared factory; ForwardState is a // site-storage-only concern (defaulted to Pending by the SQLite writer), not a // field on the canonical record. private static AuditEvent NewPendingEvent(Guid id) => ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered, eventId: id, occurredAtUtc: new DateTime(2026, 5, 21, 9, 0, 0, DateTimeKind.Utc), target: "ext-system-1", sourceSiteId: "site-1"); [Fact] public async Task SiteAuditEvent_DrainsToCentral_AndFlipsSiteRowToForwarded() { // ── Central side ────────────────────────────────────────────────── // Real AuditLogIngestActor over an in-memory repository (test-mode ctor). var centralRepo = new InMemoryAuditLogRepository(); var ingestActor = Sys.ActorOf(Props.Create(() => new ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogIngestActor( centralRepo, NullLogger.Instance))); // Real CentralCommunicationActor. Its periodic site-address refresh // resolves an ISiteRepository from this provider; an empty result keeps // the refresh a clean no-op and never touches the audit-ingest path. var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync().Returns(Array.Empty()); var centralServices = new ServiceCollection(); centralServices.AddScoped(_ => siteRepo); var centralProvider = centralServices.BuildServiceProvider(); var centralCommActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor( centralProvider, Substitute.For(), TimeSpan.FromSeconds(5)))); centralCommActor.Tell(new RegisterAuditIngest(ingestActor)); // ── Site side ───────────────────────────────────────────────────── // Real SqliteAuditWriter on a file-backed SQLite db (the site hot-path // + Pending queue). A temp file so it survives across DI scopes. var dbPath = Path.Combine(Path.GetTempPath(), $"auditpush-{Guid.NewGuid():N}.db"); var writerOptions = Options.Create(new SqliteAuditWriterOptions { DatabasePath = dbPath }); var nodeIdentity = Substitute.For(); nodeIdentity.NodeName.Returns((string?)null); await using var writer = new SqliteAuditWriter( writerOptions, NullLogger.Instance, nodeIdentity); // Real SiteCommunicationActor. Its site→central transport is the in-process bridge that // Tells the central actor (standing in for GrpcCentralTransport dialling CentralControlService). var siteCommActor = Sys.ActorOf(Props.Create(() => new SiteCommunicationActor( "site-1", new CommunicationOptions(), CreateTestProbe().Ref, // deployment-manager proxy is unused here null, null, new BridgeCentralTransport(centralCommActor)))); // The production site audit push client — the unit under integration. var auditClient = new ClusterClientSiteAuditClient( siteCommActor, TimeSpan.FromSeconds(5)); // Real SiteAuditTelemetryActor drains the writer's Pending queue and // pushes via the client. Fast intervals so the test completes quickly. var telemetryOptions = Options.Create(new SiteAuditTelemetryOptions { BatchSize = 256, BusyIntervalSeconds = 1, IdleIntervalSeconds = 1, }); Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor( writer, auditClient, telemetryOptions, NullLogger.Instance))); // ── Act ─────────────────────────────────────────────────────────── // Write an audit event onto the site SQLite hot-path. It lands Pending. var eventId = Guid.NewGuid(); await writer.WriteAsync(NewPendingEvent(eventId)); // ── Assert ──────────────────────────────────────────────────────── // Within ~10s the drain loop pushes the event to central AND flips the // site row to Forwarded. await AwaitAssertAsync(async () => { // Central received and persisted the row. Assert.Contains(centralRepo.Rows, r => r.EventId == eventId); // The site row reached AuditForwardState.Forwarded specifically — // not merely "no longer Pending" (a Reconciled row would also leave // ReadPendingAsync, so we assert the positive Forwarded state). // C3 (Task 2.5): ForwardState is a site-storage-only column, no longer a // field on the canonical record. ReadForwardedAsync only returns rows in // the Forwarded state, so a single match here proves the row reached it. var forwarded = await writer.ReadForwardedAsync(256, CancellationToken.None); Assert.Single(forwarded, r => r.EventId == eventId); }, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(250)); // The central-persisted row carries the central-stamped IngestedAtUtc. var ingested = centralRepo.Rows.Single(r => r.EventId == eventId); Assert.NotNull(ingested.AsRow().IngestedAtUtc); // Cleanup the temp SQLite file. try { File.Delete(dbPath); } catch { /* best-effort */ } } }