feat(comms): site-side PullAuditEvents handler (#23 M6)

This commit is contained in:
Joseph Doherty
2026-05-20 17:58:43 -04:00
parent 25d9acbce3
commit 640fd07454
10 changed files with 678 additions and 36 deletions

View File

@@ -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.
}
}