feat(comms): site-side PullAuditEvents handler (#23 M6)
This commit is contained in:
@@ -9,6 +9,7 @@ using ScadaLink.AuditLog.Site.Telemetry;
|
||||
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
@@ -204,4 +204,153 @@ public class SqliteAuditWriterWriteTests
|
||||
await writer.MarkForwardedAsync(phantomIds);
|
||||
// No assertion needed: the call must complete without throwing.
|
||||
}
|
||||
|
||||
// ----- M6 reconciliation pull surface ----- //
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN));
|
||||
await using var _ = writer;
|
||||
|
||||
var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
var evts = new[]
|
||||
{
|
||||
NewEvent(occurredAtUtc: baseTime.AddSeconds(5)),
|
||||
NewEvent(occurredAtUtc: baseTime.AddSeconds(1)),
|
||||
NewEvent(occurredAtUtc: baseTime.AddSeconds(3)),
|
||||
NewEvent(occurredAtUtc: baseTime.AddSeconds(2)),
|
||||
NewEvent(occurredAtUtc: baseTime.AddSeconds(4)),
|
||||
};
|
||||
foreach (var e in evts) await writer.WriteAsync(e);
|
||||
|
||||
// Flip half to Forwarded — they must still surface in the reconciliation pull
|
||||
// because central hasn't confirmed they were ingested yet.
|
||||
await writer.MarkForwardedAsync(new[] { evts[0].EventId, evts[2].EventId });
|
||||
|
||||
var rows = await writer.ReadPendingSinceAsync(sinceUtc: DateTime.MinValue, batchSize: 3);
|
||||
|
||||
Assert.Equal(3, rows.Count);
|
||||
Assert.Equal(baseTime.AddSeconds(1), rows[0].OccurredAtUtc);
|
||||
Assert.Equal(baseTime.AddSeconds(2), rows[1].OccurredAtUtc);
|
||||
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc()
|
||||
{
|
||||
var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc));
|
||||
await using var _w = writer;
|
||||
|
||||
var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
var old = NewEvent(occurredAtUtc: baseTime.AddSeconds(-30));
|
||||
var newer1 = NewEvent(occurredAtUtc: baseTime.AddSeconds(10));
|
||||
var newer2 = NewEvent(occurredAtUtc: baseTime.AddSeconds(20));
|
||||
|
||||
await writer.WriteAsync(old);
|
||||
await writer.WriteAsync(newer1);
|
||||
await writer.WriteAsync(newer2);
|
||||
|
||||
var rows = await writer.ReadPendingSinceAsync(sinceUtc: baseTime, batchSize: 10);
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Contains(rows, r => r.EventId == newer1.EventId);
|
||||
Assert.Contains(rows, r => r.EventId == newer2.EventId);
|
||||
Assert.DoesNotContain(rows, r => r.EventId == old.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_ExcludesReconciledRows()
|
||||
{
|
||||
var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_ExcludesReconciledRows));
|
||||
await using var _w = writer;
|
||||
|
||||
var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
var pending = NewEvent(occurredAtUtc: baseTime);
|
||||
var reconciled = NewEvent(occurredAtUtc: baseTime.AddSeconds(1));
|
||||
|
||||
await writer.WriteAsync(pending);
|
||||
await writer.WriteAsync(reconciled);
|
||||
await writer.MarkReconciledAsync(new[] { reconciled.EventId });
|
||||
|
||||
var rows = await writer.ReadPendingSinceAsync(sinceUtc: DateTime.MinValue, batchSize: 10);
|
||||
|
||||
Assert.Single(rows);
|
||||
Assert.Equal(pending.EventId, rows[0].EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws()
|
||||
{
|
||||
var (writer, _) = CreateWriter(nameof(ReadPendingSinceAsync_InvalidBatchSize_Throws));
|
||||
await using var _w = writer;
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
||||
() => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: 0));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
||||
() => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled));
|
||||
await using var _ = writer;
|
||||
|
||||
var a = NewEvent();
|
||||
var b = NewEvent();
|
||||
var c = NewEvent();
|
||||
await writer.WriteAsync(a);
|
||||
await writer.WriteAsync(b);
|
||||
await writer.WriteAsync(c);
|
||||
|
||||
// b is currently Forwarded; a and c are Pending.
|
||||
await writer.MarkForwardedAsync(new[] { b.EventId });
|
||||
|
||||
await writer.MarkReconciledAsync(new[] { a.EventId, b.EventId, c.EventId });
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var byState = new Dictionary<string, long>();
|
||||
while (reader.Read())
|
||||
{
|
||||
byState[reader.GetString(0)] = reader.GetInt64(1);
|
||||
}
|
||||
|
||||
Assert.Equal(3, byState[AuditForwardState.Reconciled.ToString()]);
|
||||
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
|
||||
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched));
|
||||
await using var _ = writer;
|
||||
|
||||
var a = NewEvent();
|
||||
await writer.WriteAsync(a);
|
||||
await writer.MarkReconciledAsync(new[] { a.EventId });
|
||||
// Re-call must not throw and must leave the single row Reconciled.
|
||||
await writer.MarkReconciledAsync(new[] { a.EventId });
|
||||
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", a.EventId.ToString());
|
||||
|
||||
Assert.Equal(AuditForwardState.Reconciled.ToString(), cmd.ExecuteScalar() as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkReconciledAsync_NonExistentId_NoThrow()
|
||||
{
|
||||
var (writer, _) = CreateWriter(nameof(MarkReconciledAsync_NonExistentId_NoThrow));
|
||||
await using var _w = writer;
|
||||
|
||||
await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() });
|
||||
// Completes without throwing.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ScadaLink.AuditLog.Site.Telemetry;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
|
||||
namespace ScadaLink.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle A A2 tests for <see cref="SiteStreamGrpcServer.PullAuditEvents"/>.
|
||||
/// Verifies the request → ISiteAuditQueue.ReadPendingSinceAsync → response →
|
||||
/// MarkReconciledAsync round-trip through the gRPC handler. The queue is an
|
||||
/// NSubstitute stub so the tests never touch SQLite.
|
||||
/// </summary>
|
||||
public class SiteStreamPullAuditEventsTests : TestKit
|
||||
{
|
||||
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
|
||||
private SiteStreamGrpcServer CreateServer() =>
|
||||
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
|
||||
private static ServerCallContext NewContext(CancellationToken ct = default)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(ct);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static AuditEvent NewEvent(DateTime? occurredAt = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAt
|
||||
?? DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = "site-1",
|
||||
PayloadTruncated = false,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_NoQueueWired_ReturnsEmptyResponse()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Intentionally do NOT call SetSiteAuditQueue — simulates a central-only
|
||||
// host or a wiring-incomplete startup window.
|
||||
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddMinutes(-5)),
|
||||
BatchSize = 100,
|
||||
};
|
||||
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Empty(response.Events);
|
||||
Assert.False(response.MoreAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_With5PendingRows_ReturnsAllFiveDtos_AndFlipsToReconciled()
|
||||
{
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var events = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList();
|
||||
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((IReadOnlyList<AuditEvent>)events);
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 100, // larger than returned count so MoreAvailable should be false
|
||||
};
|
||||
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Equal(5, response.Events.Count);
|
||||
Assert.False(response.MoreAvailable); // 5 < 100
|
||||
var expectedIds = events.Select(e => e.EventId.ToString()).ToHashSet();
|
||||
Assert.True(expectedIds.SetEquals(response.Events.Select(d => d.EventId).ToHashSet()));
|
||||
|
||||
// Verify MarkReconciledAsync received the same 5 ids (best-effort flip).
|
||||
await queue.Received(1).MarkReconciledAsync(
|
||||
Arg.Is<IReadOnlyList<Guid>>(ids => ids.Count == 5 &&
|
||||
ids.ToHashSet().SetEquals(events.Select(e => e.EventId))),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_RowsOlderThanSinceUtc_Excluded()
|
||||
{
|
||||
// The handler delegates the since-utc filter to ReadPendingSinceAsync;
|
||||
// this test verifies it passes the request value through verbatim
|
||||
// (no clock skew, no off-by-one) and that an empty queue response
|
||||
// yields an empty gRPC response.
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var capturedSince = DateTime.MinValue;
|
||||
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
capturedSince = call.ArgAt<DateTime>(0);
|
||||
return (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>();
|
||||
});
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
|
||||
var since = DateTime.SpecifyKind(new DateTime(2026, 5, 20, 9, 30, 0), DateTimeKind.Utc);
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(since),
|
||||
BatchSize = 50,
|
||||
};
|
||||
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Empty(response.Events);
|
||||
Assert.False(response.MoreAvailable);
|
||||
Assert.Equal(since, capturedSince);
|
||||
// Empty result → no MarkReconciledAsync call (no rows to flip).
|
||||
await queue.DidNotReceive().MarkReconciledAsync(
|
||||
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_BatchSize3_Returns3Rows_MoreAvailableTrue()
|
||||
{
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var events = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
|
||||
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((IReadOnlyList<AuditEvent>)events);
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 3,
|
||||
};
|
||||
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Equal(3, response.Events.Count);
|
||||
// saturated batch → central needs to know to issue a follow-up pull
|
||||
Assert.True(response.MoreAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_MarkReconciledThrows_ResponseStillReturned()
|
||||
{
|
||||
// The Reconciled flip is best-effort — if it fails, the response must
|
||||
// still surface so central can ingest the rows (and dedup on EventId
|
||||
// when it pulls them again).
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var events = Enumerable.Range(0, 2).Select(_ => NewEvent()).ToList();
|
||||
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((IReadOnlyList<AuditEvent>)events);
|
||||
queue.MarkReconciledAsync(Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("SQLite disposed mid-call"));
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 100,
|
||||
};
|
||||
|
||||
// Must NOT throw — the response is built before the flip and returned
|
||||
// regardless of the flip outcome.
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Equal(2, response.Events.Count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user