Files
scadalink-design/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/ClusterClientSiteAuditClientTests.cs

203 lines
7.7 KiB
C#

using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Telemetry;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Tests for <see cref="ClusterClientSiteAuditClient"/> — the production
/// <see cref="ISiteStreamAuditClient"/> binding wired by the Host for site
/// roles. The client maps the proto-DTO batches produced by
/// <see cref="SiteAuditTelemetryActor"/> into the Akka
/// <see cref="IngestAuditEventsCommand"/> / <see cref="IngestCachedTelemetryCommand"/>
/// messages, Asks the site's <c>SiteCommunicationActor</c> (which forwards over
/// ClusterClient to central), and maps the reply back into an
/// <see cref="IngestAck"/>.
/// </summary>
/// <remarks>
/// A <see cref="TestProbe"/> stands in for the <c>SiteCommunicationActor</c>:
/// it lets the tests assert the exact command shape AND drive the reply (or
/// withhold one to exercise the Ask-timeout path).
/// </remarks>
public class ClusterClientSiteAuditClientTests : TestKit
{
/// <summary>Short Ask timeout so the timeout test completes quickly.</summary>
private static readonly TimeSpan AskTimeout = TimeSpan.FromMilliseconds(500);
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 AuditEventBatch BatchOf(IEnumerable<AuditEvent> events)
{
var batch = new AuditEventBatch();
foreach (var e in events)
{
batch.Events.Add(AuditEventMapper.ToDto(e));
}
return batch;
}
private static SiteCallOperationalDto NewOperationalDto() => new()
{
TrackedOperationId = Guid.NewGuid().ToString(),
Channel = "ApiOutbound",
Target = "ext-system-1",
SourceSite = "site-1",
Status = "Submitted",
RetryCount = 0,
LastError = string.Empty,
CreatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)),
UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)),
};
[Fact]
public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var events = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
var batch = BatchOf(events);
var task = sut.IngestAuditEventsAsync(batch, CancellationToken.None);
// The probe receives exactly one IngestAuditEventsCommand carrying the
// batch's events; it replies with every EventId accepted.
var cmd = probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
Assert.Equal(3, cmd.Events.Count);
Assert.Equal(
events.Select(e => e.EventId).ToHashSet(),
cmd.Events.Select(e => e.EventId).ToHashSet());
probe.Reply(new IngestAuditEventsReply(events.Select(e => e.EventId).ToList()));
var ack = await task;
Assert.Equal(
events.Select(e => e.EventId.ToString()).ToHashSet(),
ack.AcceptedEventIds.ToHashSet());
}
[Fact]
public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var events = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList();
var accepted = events.Take(3).Select(e => e.EventId).ToList();
var task = sut.IngestAuditEventsAsync(BatchOf(events), CancellationToken.None);
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
probe.Reply(new IngestAuditEventsReply(accepted));
var ack = await task;
Assert.Equal(3, ack.AcceptedEventIds.Count);
Assert.Equal(
accepted.Select(id => id.ToString()).ToHashSet(),
ack.AcceptedEventIds.ToHashSet());
}
[Fact]
public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var batch = BatchOf(new[] { NewEvent() });
// The probe receives the command but never replies — the Ask times out.
// The contract: a timeout MUST surface as a thrown exception so the
// SiteAuditTelemetryActor drain loop leaves the rows Pending.
var task = sut.IngestAuditEventsAsync(batch, CancellationToken.None);
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
await Assert.ThrowsAnyAsync<Exception>(() => task);
}
[Fact]
public async Task IngestAuditEventsAsync_FaultedReply_Throws()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None);
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
// A Status.Failure from central (Task 1: central does not swallow an
// ingest fault into an empty ack) must propagate as a thrown exception.
probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted")));
await Assert.ThrowsAnyAsync<Exception>(() => task);
}
[Fact]
public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var events = Enumerable.Range(0, 2).Select(_ => NewEvent()).ToList();
var batch = new CachedTelemetryBatch();
foreach (var e in events)
{
batch.Packets.Add(new CachedTelemetryPacket
{
AuditEvent = AuditEventMapper.ToDto(e),
Operational = NewOperationalDto(),
});
}
var task = sut.IngestCachedTelemetryAsync(batch, CancellationToken.None);
// The probe receives an IngestCachedTelemetryCommand (NOT an
// IngestAuditEventsCommand) with one entry per packet.
var cmd = probe.ExpectMsg<IngestCachedTelemetryCommand>(TimeSpan.FromSeconds(3));
Assert.Equal(2, cmd.Entries.Count);
Assert.Equal(
events.Select(e => e.EventId).ToHashSet(),
cmd.Entries.Select(en => en.Audit.EventId).ToHashSet());
probe.Reply(new IngestCachedTelemetryReply(events.Select(e => e.EventId).ToList()));
var ack = await task;
Assert.Equal(
events.Select(e => e.EventId.ToString()).ToHashSet(),
ack.AcceptedEventIds.ToHashSet());
}
[Fact]
public async Task IngestCachedTelemetryAsync_AskTimeout_Throws()
{
var probe = CreateTestProbe();
var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout);
var batch = new CachedTelemetryBatch();
batch.Packets.Add(new CachedTelemetryPacket
{
AuditEvent = AuditEventMapper.ToDto(NewEvent()),
Operational = NewOperationalDto(),
});
var task = sut.IngestCachedTelemetryAsync(batch, CancellationToken.None);
probe.ExpectMsg<IngestCachedTelemetryCommand>(TimeSpan.FromSeconds(3));
await Assert.ThrowsAnyAsync<Exception>(() => task);
}
}