diff --git a/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs b/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs new file mode 100644 index 0000000..38d7468 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs @@ -0,0 +1,13 @@ +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Audit Log (#23) telemetry envelope sent from a site to central over gRPC. +/// At-least-once delivery; central is idempotent on . +/// See Component-AuditLog.md "Ingestion" for the handoff contract. +/// +public sealed record AuditTelemetryEnvelope( + Guid EnvelopeId, + string SourceSiteId, + IReadOnlyList Events); diff --git a/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs new file mode 100644 index 0000000..df70a30 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs @@ -0,0 +1,11 @@ +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Audit Log (#23) periodic reconciliation pull request: central asks a site for +/// audit events since the given UTC watermark, up to . +/// Acts as the fallback when streaming telemetry is lost. See Component-AuditLog.md "Ingestion". +/// +public sealed record PullAuditEventsRequest( + string SourceSiteId, + DateTime SinceUtc, + int BatchSize); diff --git a/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs new file mode 100644 index 0000000..c752304 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs @@ -0,0 +1,12 @@ +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Audit Log (#23) periodic reconciliation pull response: the next batch of site +/// audit events plus a flag signalling the caller +/// to advance the watermark and pull again. See Component-AuditLog.md "Ingestion". +/// +public sealed record PullAuditEventsResponse( + IReadOnlyList Events, + bool MoreAvailable); diff --git a/tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs new file mode 100644 index 0000000..1bf2f9b --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs @@ -0,0 +1,105 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Messages.Integration; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Tests.Messages.Integration; + +/// +/// Audit Log (#23) telemetry handoff: envelope + pull request/response DTOs. +/// At-least-once from sites; idempotent at central on . +/// +public class AuditTelemetryMessagesTests +{ + private static AuditEvent MakeEvent(Guid? id = null) => new() + { + EventId = id ?? Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + PayloadTruncated = false + }; + + [Fact] + public void AuditTelemetryEnvelope_ConstructsWithThreeEvents_AndIsEnumerable() + { + var envelopeId = Guid.NewGuid(); + var events = new List { MakeEvent(), MakeEvent(), MakeEvent() }; + + var envelope = new AuditTelemetryEnvelope(envelopeId, "site-01", events); + + Assert.Equal(envelopeId, envelope.EnvelopeId); + Assert.Equal("site-01", envelope.SourceSiteId); + Assert.Equal(3, envelope.Events.Count); + + // Enumerable round-trip + var collected = new List(); + foreach (var e in envelope.Events) + { + collected.Add(e); + } + Assert.Equal(3, collected.Count); + } + + [Fact] + public void AuditTelemetryEnvelope_IsImmutable_RecordEqualityOnReferenceIdentityOfList() + { + // The record's value equality compares the IReadOnlyList reference; two envelopes + // built with the same list instance + same fields must be equal, but using a + // different list instance (even with equal content) must NOT be equal. + var events = new List { MakeEvent() } as IReadOnlyList; + var envelopeId = Guid.NewGuid(); + var a = new AuditTelemetryEnvelope(envelopeId, "site-01", events); + var b = new AuditTelemetryEnvelope(envelopeId, "site-01", events); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + + var withDifferentSite = a with { SourceSiteId = "site-02" }; + Assert.NotEqual(a, withDifferentSite); + Assert.Equal("site-02", withDifferentSite.SourceSiteId); + Assert.Equal("site-01", a.SourceSiteId); + } + + [Fact] + public void PullAuditEventsRequest_ConstructsAndIsImmutable() + { + var since = new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc); + var request = new PullAuditEventsRequest("site-01", since, 100); + + Assert.Equal("site-01", request.SourceSiteId); + Assert.Equal(since, request.SinceUtc); + Assert.Equal(100, request.BatchSize); + + var bigger = request with { BatchSize = 500 }; + Assert.Equal(100, request.BatchSize); + Assert.Equal(500, bigger.BatchSize); + } + + [Fact] + public void PullAuditEventsResponse_ConstructsWithMoreAvailableTrue_AndIsEnumerable() + { + var events = new List { MakeEvent(), MakeEvent() }; + var response = new PullAuditEventsResponse(events, MoreAvailable: true); + + Assert.True(response.MoreAvailable); + Assert.Equal(2, response.Events.Count); + + var collected = new List(); + foreach (var e in response.Events) + { + collected.Add(e); + } + Assert.Equal(2, collected.Count); + } + + [Fact] + public void PullAuditEventsResponse_WithExpression_ChangesSingleField() + { + var response = new PullAuditEventsResponse(new List(), MoreAvailable: false); + var updated = response with { MoreAvailable = true }; + + Assert.False(response.MoreAvailable); + Assert.True(updated.MoreAvailable); + } +}