using Akka.Actor; using Akka.TestKit.Xunit2; using Google.Protobuf.WellKnownTypes; using ScadaLink.AuditLog.Site.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; /// /// Tests for — the production /// binding wired by the Host for site /// roles. The client maps the proto-DTO batches produced by /// into the Akka /// / /// messages, Asks the site's SiteCommunicationActor (which forwards over /// ClusterClient to central), and maps the reply back into an /// . /// /// /// A stands in for the SiteCommunicationActor: /// it lets the tests assert the exact command shape AND drive the reply (or /// withhold one to exercise the Ask-timeout path). /// public class ClusterClientSiteAuditClientTests : TestKit { /// Short Ask timeout so the timeout test completes quickly. 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 events) { var batch = new AuditEventBatch(); foreach (var e in events) { batch.Events.Add(AuditEventDtoMapper.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(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(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(TimeSpan.FromSeconds(3)); await Assert.ThrowsAnyAsync(() => 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(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(() => 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 = AuditEventDtoMapper.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(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 = AuditEventDtoMapper.ToDto(NewEvent()), Operational = NewOperationalDto(), }); var task = sut.IngestCachedTelemetryAsync(batch, CancellationToken.None); probe.ExpectMsg(TimeSpan.FromSeconds(3)); await Assert.ThrowsAnyAsync(() => task); } }