using Akka.Actor; using Akka.TestKit.Xunit2; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Enums; using ScadaLink.Communication.Grpc; namespace ScadaLink.AuditLog.Tests.Site.Telemetry; /// /// Bundle D D1 tests for . The actor drains /// the site SQLite queue via , pushes batches via /// , and flips ack'd rows to Forwarded. /// Both collaborators are NSubstitute mocks so the tests never touch real /// SQLite or gRPC. /// public class SiteAuditTelemetryActorTests : TestKit { private readonly ISiteAuditQueue _queue = Substitute.For(); private readonly ISiteStreamAuditClient _client = Substitute.For(); /// /// Fast options so tests don't stall waiting for the scheduler. 1s busy / /// 2s idle still exercises the busy-vs-idle branching, but each test /// completes in < 5 s wall-clock. /// private static IOptions Opts( int batchSize = 256, int busySeconds = 1, int idleSeconds = 2) => Options.Create(new SiteAuditTelemetryOptions { BatchSize = batchSize, BusyIntervalSeconds = busySeconds, IdleIntervalSeconds = idleSeconds, }); private IActorRef CreateActor(IOptions? options = null) => Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor( _queue, _client, options ?? Opts(), NullLogger.Instance))); private static AuditEvent NewEvent(Guid? id = null) => new() { EventId = id ?? Guid.NewGuid(), OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered, SourceSiteId = "site-1", ForwardState = AuditForwardState.Pending, }; private static IngestAck AckAll(IReadOnlyList events) { var ack = new IngestAck(); foreach (var e in events) { ack.AcceptedEventIds.Add(e.EventId.ToString()); } return ack; } [Fact] public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded() { // Arrange — 50 pending rows on the first read, then empty on subsequent // reads so the actor settles after one productive drain. var pending = Enumerable.Range(0, 50).Select(_ => NewEvent()).ToList(); _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) .Returns( Task.FromResult>(pending), Task.FromResult>(Array.Empty())); AuditEventBatch? capturedBatch = null; _client.IngestAuditEventsAsync(Arg.Any(), Arg.Any()) .Returns(call => { capturedBatch = call.Arg(); return Task.FromResult(AckAll(pending)); }); // Act CreateActor(); // Assert — give the scheduler time to fire the initial Drain tick. await AwaitAssertAsync(async () => { await _client.Received(1).IngestAuditEventsAsync( Arg.Any(), Arg.Any()); await _queue.Received(1).MarkForwardedAsync( Arg.Is>(g => g.Count == 50), Arg.Any()); }, TimeSpan.FromSeconds(5)); Assert.NotNull(capturedBatch); Assert.Equal(50, capturedBatch!.Events.Count); var expected = pending.Select(e => e.EventId).ToHashSet(); await _queue.Received(1).MarkForwardedAsync( Arg.Is>(g => g.ToHashSet().SetEquals(expected)), Arg.Any()); } [Fact] public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries() { // Arrange — first read returns 3 rows; the gRPC client throws on the // first push, then succeeds on the second. After the second push the // queue returns empty so the actor settles. var batch = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList(); _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) .Returns( Task.FromResult>(batch), Task.FromResult>(batch), Task.FromResult>(Array.Empty())); var calls = 0; _client.IngestAuditEventsAsync(Arg.Any(), Arg.Any()) .Returns(_ => { calls++; if (calls == 1) { throw new InvalidOperationException("simulated gRPC failure"); } return Task.FromResult(AckAll(batch)); }); // Act CreateActor(); // Assert — eventually MarkForwardedAsync is called exactly once (after // the retry succeeded). The first failure must NOT have called // MarkForwardedAsync because the rows stay Pending. await AwaitAssertAsync(async () => { await _queue.Received(1).MarkForwardedAsync( Arg.Any>(), Arg.Any()); }, TimeSpan.FromSeconds(10)); Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}"); } [Fact] public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall() { // Arrange — queue always empty. _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); // Idle interval = 2 s. Pause 3 s after the first tick (1 s busy on // PreStart) and assert the empty-queue branch did NOT push to the // client. CreateActor(Opts(busySeconds: 1, idleSeconds: 2)); // Allow the initial tick (~1 s) + a generous window for the idle re-tick. await Task.Delay(TimeSpan.FromSeconds(3)); await _client.DidNotReceiveWithAnyArgs().IngestAuditEventsAsync(default!, default); // ReadPendingAsync was called at least once (initial tick), and at // most twice within the 3 s window (initial + one idle re-tick). var readCalls = _queue.ReceivedCalls() .Count(c => c.GetMethodInfo().Name == nameof(ISiteAuditQueue.ReadPendingAsync)); Assert.InRange(readCalls, 1, 2); } [Fact] public async Task Drain_NonZeroPending_SchedulesAtBusyInterval() { // Arrange — every read returns 1 row. With busy=1s the actor should // re-drain quickly, producing multiple client calls inside a short // window. var single = new List { NewEvent() }; _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(single)); _client.IngestAuditEventsAsync(Arg.Any(), Arg.Any()) .Returns(call => Task.FromResult(AckAll(single))); CreateActor(Opts(busySeconds: 1, idleSeconds: 10)); // 3-second window with busy=1s should fit at least 2 drains. await Task.Delay(TimeSpan.FromSeconds(3)); var pushCalls = _client.ReceivedCalls() .Count(c => c.GetMethodInfo().Name == nameof(ISiteStreamAuditClient.IngestAuditEventsAsync)); Assert.True(pushCalls >= 2, $"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}"); } [Fact] public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted() { // Arrange — 5 rows pushed, but the central ack only lists 3. var rows = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList(); var ackedIds = rows.Take(3).Select(r => r.EventId).ToList(); _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) .Returns( Task.FromResult>(rows), Task.FromResult>(Array.Empty())); var partialAck = new IngestAck(); foreach (var id in ackedIds) { partialAck.AcceptedEventIds.Add(id.ToString()); } _client.IngestAuditEventsAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(partialAck)); // Act CreateActor(); await AwaitAssertAsync(async () => { await _queue.Received(1).MarkForwardedAsync( Arg.Any>(), Arg.Any()); }, TimeSpan.FromSeconds(5)); // Assert — exactly the 3 ack'd ids made it to MarkForwardedAsync, not // the other 2. var ackedSet = ackedIds.ToHashSet(); await _queue.Received(1).MarkForwardedAsync( Arg.Is>(g => g.Count == 3 && g.ToHashSet().SetEquals(ackedSet)), Arg.Any()); } }