From 73719ee0666d6f0baf6056884411038dd884a817 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 14:39:24 -0400 Subject: [PATCH] feat(auditlog): extend ISiteStreamAuditClient with IngestCachedTelemetryAsync (#23 M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the second site→central RPC seam alongside the existing M2 IngestAuditEventsAsync. The Bundle D proto already lit up IngestCachedTelemetry (CachedTelemetryBatch / IngestAck) so this commit just plumbs the client-side abstraction: * ISiteStreamAuditClient gains IngestCachedTelemetryAsync(batch, ct). * NoOpSiteStreamAuditClient implements it returning an empty IngestAck (same shape as M2 — production gRPC client lands in M6). * SyncCallEmissionEndToEndTests' DirectActorSiteStreamAuditClient stub throws NotSupportedException from the new method so a regression that accidentally routes a cached packet through the sync stub fails loudly. * New NoOpSiteStreamAuditClientTests cover the null-guard + empty-ack contract for both batch shapes. Bundle E task E1. --- .../Site/Telemetry/ISiteStreamAuditClient.cs | 19 ++++++ .../Telemetry/NoOpSiteStreamAuditClient.cs | 12 ++++ .../SyncCallEmissionEndToEndTests.cs | 12 ++++ .../NoOpSiteStreamAuditClientTests.cs | 58 +++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 tests/ScadaLink.AuditLog.Tests/Site/Telemetry/NoOpSiteStreamAuditClientTests.cs diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs index c25b05a..6314bba 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs @@ -20,4 +20,23 @@ public interface ISiteStreamAuditClient /// in the site SQLite queue. /// Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct); + + /// + /// Pushes the combined (Audit Log #23 / M3) + /// to the central IngestCachedTelemetry RPC. Each packet carries both + /// the audit row and the operational SiteCalls upsert; central writes + /// both in a single MS SQL transaction. Returns the same + /// shape as so + /// the M3 site-side forwarder can flip the underlying audit rows to + /// + /// once central has acknowledged them. + /// + /// + /// The production gRPC-backed implementation lands in M6 (no site→central + /// gRPC channel exists today); until then the default + /// binding returns an empty ack and + /// integration tests substitute a direct-actor client that routes the batch + /// straight into the in-process AuditLogIngestActor. + /// + Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct); } diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs index b1a0190..b83a215 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs @@ -38,4 +38,16 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient // Pending until M6's real client (or a Bundle H test stub) takes over. return Task.FromResult(EmptyAck); } + + /// + public Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(batch); + // Empty ack — same rationale as IngestAuditEventsAsync. The M3 + // CachedCallTelemetryForwarder still writes the audit + tracking rows to + // the site SQLite stores authoritatively; central-side state only + // materialises once M6's real gRPC client (or a Bundle G test stub) is + // wired in. + return Task.FromResult(EmptyAck); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs index 5f16ce9..755ce52 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs @@ -337,5 +337,17 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture + /// Bundle E E1: the sync-only end-to-end suite does not exercise the + /// cached-telemetry path. Throw if it is ever called from these tests + /// so a regression that accidentally routes a cached packet through + /// the sync stub fails loudly rather than silently no-op'ing. + /// + public Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct) + { + throw new NotSupportedException( + "Sync-call test stub does not implement cached telemetry — use the M3 cached-call client."); + } } } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/NoOpSiteStreamAuditClientTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/NoOpSiteStreamAuditClientTests.cs new file mode 100644 index 0000000..b673bbb --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/NoOpSiteStreamAuditClientTests.cs @@ -0,0 +1,58 @@ +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.AuditLog.Tests.Site.Telemetry; + +/// +/// Bundle E E1 tests for . The NoOp +/// client is the default binding until M6 +/// delivers the gRPC-backed implementation; both IngestAuditEventsAsync +/// (M2) and IngestCachedTelemetryAsync (M3) must return an empty ack +/// (no rows flipped to Forwarded) without throwing or partially handling the +/// batch. +/// +public class NoOpSiteStreamAuditClientTests +{ + [Fact] + public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck() + { + var sut = new NoOpSiteStreamAuditClient(); + var batch = new CachedTelemetryBatch(); + + var ack = await sut.IngestCachedTelemetryAsync(batch, CancellationToken.None); + + Assert.NotNull(ack); + Assert.Empty(ack.AcceptedEventIds); + } + + [Fact] + public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck() + { + var sut = new NoOpSiteStreamAuditClient(); + var batch = new CachedTelemetryBatch(); + batch.Packets.Add(new CachedTelemetryPacket + { + AuditEvent = new AuditEventDto + { + EventId = Guid.NewGuid().ToString(), + Channel = "ApiOutbound", + Kind = "CachedSubmit", + Status = "Submitted", + }, + }); + + var ack = await sut.IngestCachedTelemetryAsync(batch, CancellationToken.None); + + // No EventIds flipped — NoOp does not forward to anyone. + Assert.Empty(ack.AcceptedEventIds); + } + + [Fact] + public async Task IngestCachedTelemetryAsync_NullBatch_Throws() + { + var sut = new NoOpSiteStreamAuditClient(); + + await Assert.ThrowsAsync( + () => sut.IngestCachedTelemetryAsync(null!, CancellationToken.None)); + } +}