feat(auditlog): SiteAuditTelemetryActor + ISiteStreamAuditClient seam (#23)
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D D1 tests for <see cref="SiteAuditTelemetryActor"/>. The actor drains
|
||||
/// the site SQLite queue via <see cref="ISiteAuditQueue"/>, pushes batches via
|
||||
/// <see cref="ISiteStreamAuditClient"/>, and flips ack'd rows to Forwarded.
|
||||
/// Both collaborators are NSubstitute mocks so the tests never touch real
|
||||
/// SQLite or gRPC.
|
||||
/// </summary>
|
||||
public class SiteAuditTelemetryActorTests : TestKit
|
||||
{
|
||||
private readonly ISiteAuditQueue _queue = Substitute.For<ISiteAuditQueue>();
|
||||
private readonly ISiteStreamAuditClient _client = Substitute.For<ISiteStreamAuditClient>();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static IOptions<SiteAuditTelemetryOptions> Opts(
|
||||
int batchSize = 256,
|
||||
int busySeconds = 1,
|
||||
int idleSeconds = 2) =>
|
||||
Options.Create(new SiteAuditTelemetryOptions
|
||||
{
|
||||
BatchSize = batchSize,
|
||||
BusyIntervalSeconds = busySeconds,
|
||||
IdleIntervalSeconds = idleSeconds,
|
||||
});
|
||||
|
||||
private IActorRef CreateActor(IOptions<SiteAuditTelemetryOptions>? options = null) =>
|
||||
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
|
||||
_queue,
|
||||
_client,
|
||||
options ?? Opts(),
|
||||
NullLogger<SiteAuditTelemetryActor>.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<AuditEvent> 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<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(pending),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
AuditEventBatch? capturedBatch = null;
|
||||
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
capturedBatch = call.Arg<AuditEventBatch>();
|
||||
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<AuditEventBatch>(), Arg.Any<CancellationToken>());
|
||||
await _queue.Received(1).MarkForwardedAsync(
|
||||
Arg.Is<IReadOnlyList<Guid>>(g => g.Count == 50), Arg.Any<CancellationToken>());
|
||||
}, 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<IReadOnlyList<Guid>>(g => g.ToHashSet().SetEquals(expected)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(batch),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(batch),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var calls = 0;
|
||||
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
|
||||
.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<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
||||
}, 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<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
// 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<AuditEvent> { NewEvent() };
|
||||
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(single));
|
||||
|
||||
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
|
||||
.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<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var partialAck = new IngestAck();
|
||||
foreach (var id in ackedIds)
|
||||
{
|
||||
partialAck.AcceptedEventIds.Add(id.ToString());
|
||||
}
|
||||
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(partialAck));
|
||||
|
||||
// Act
|
||||
CreateActor();
|
||||
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
await _queue.Received(1).MarkForwardedAsync(
|
||||
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
||||
}, 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<IReadOnlyList<Guid>>(g => g.Count == 3 && g.ToHashSet().SetEquals(ackedSet)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user