Replaces the B-T1 stub WriteAsync with the production hot-path: - Bounded Channel<PendingAuditEvent> (BoundedChannelFullMode.Wait, capacity from options) feeds a background ProcessWriteQueueAsync loop that drains up to BatchSize events per transaction. - The loop INSERTs each event with explicit parameter binding (enums and DateTime stored as text); duplicate EventIds (SqliteException with ErrorCode 19 SQLITE_CONSTRAINT) are swallowed as first-write-wins per alog.md §11, and the pending TCS is still completed successfully so callers see idempotent semantics. - Site rows force ForwardState = Pending on enqueue when the inbound event leaves it null — site-side default per the M2 design. - ReadPendingAsync(limit) returns oldest-first pending rows for the Bundle D telemetry actor; EventId is the deterministic tiebreaker on identical OccurredAtUtc timestamps. MarkForwardedAsync(ids) flips a batch to Forwarded in one UPDATE with a parameterised IN list. - IAsyncDisposable graceful shutdown: TryComplete the writer, await the drain (5s budget), then dispose the connection. Tests (7 new, total 16 -> 23): - WriteAsync_FreshEvent_PersistsWithForwardStatePending - WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions - WriteAsync_DuplicateEventId_FirstWriteWins_NoException - WriteAsync_ForcesForwardStatePending_IfNull - ReadPendingAsync_Returns_OldestFirst_LimitedToN - MarkForwardedAsync_FlipsRowsToForwarded - MarkForwardedAsync_NonExistentId_NoThrow
208 lines
7.7 KiB
C#
208 lines
7.7 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.AuditLog.Site;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
|
|
namespace ScadaLink.AuditLog.Tests.Site;
|
|
|
|
/// <summary>
|
|
/// Bundle B (M2-T2) hot-path tests for <see cref="SqliteAuditWriter"/>. Exercise
|
|
/// the Channel-based enqueue, the background writer's batch INSERTs, duplicate-
|
|
/// EventId swallowing, ForwardState defaulting, and the
|
|
/// <see cref="SqliteAuditWriter.ReadPendingAsync"/> /
|
|
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> support surface that
|
|
/// Bundle D's telemetry actor will call.
|
|
/// </summary>
|
|
public class SqliteAuditWriterWriteTests
|
|
{
|
|
private static (SqliteAuditWriter writer, string dataSource) CreateWriter(
|
|
string testName,
|
|
int? channelCapacity = null)
|
|
{
|
|
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
|
|
var opts = new SqliteAuditWriterOptions { DatabasePath = dataSource };
|
|
if (channelCapacity is int cap)
|
|
{
|
|
opts.ChannelCapacity = cap;
|
|
}
|
|
|
|
var writer = new SqliteAuditWriter(
|
|
Options.Create(opts),
|
|
NullLogger<SqliteAuditWriter>.Instance,
|
|
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
|
|
return (writer, dataSource);
|
|
}
|
|
|
|
private static SqliteConnection OpenVerifierConnection(string dataSource)
|
|
{
|
|
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
|
|
connection.Open();
|
|
return connection;
|
|
}
|
|
|
|
private static AuditEvent NewEvent(Guid? id = null, DateTime? occurredAtUtc = null)
|
|
{
|
|
return new AuditEvent
|
|
{
|
|
EventId = id ?? Guid.NewGuid(),
|
|
OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
|
|
Channel = AuditChannel.ApiOutbound,
|
|
Kind = AuditKind.ApiCall,
|
|
Status = AuditStatus.Delivered,
|
|
PayloadTruncated = false,
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending()
|
|
{
|
|
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsWithForwardStatePending));
|
|
await using var _ = writer;
|
|
|
|
var evt = NewEvent();
|
|
await writer.WriteAsync(evt);
|
|
|
|
using var connection = OpenVerifierConnection(dataSource);
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
|
|
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
|
|
var actual = cmd.ExecuteScalar() as string;
|
|
|
|
Assert.Equal(AuditForwardState.Pending.ToString(), actual);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions()
|
|
{
|
|
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions));
|
|
await using var _ = writer;
|
|
|
|
var events = Enumerable.Range(0, 1000).Select(_ => NewEvent()).ToList();
|
|
|
|
await Parallel.ForEachAsync(events, new ParallelOptions { MaxDegreeOfParallelism = 16 },
|
|
async (evt, ct) => await writer.WriteAsync(evt, ct));
|
|
|
|
using var connection = OpenVerifierConnection(dataSource);
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "SELECT COUNT(*) FROM AuditLog;";
|
|
var count = Convert.ToInt64(cmd.ExecuteScalar());
|
|
|
|
Assert.Equal(1000, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException()
|
|
{
|
|
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_DuplicateEventId_FirstWriteWins_NoException));
|
|
await using var _ = writer;
|
|
|
|
var sharedId = Guid.NewGuid();
|
|
var first = NewEvent(sharedId) with { Target = "first" };
|
|
var second = NewEvent(sharedId) with { Target = "second" };
|
|
|
|
await writer.WriteAsync(first);
|
|
await writer.WriteAsync(second);
|
|
|
|
using var connection = OpenVerifierConnection(dataSource);
|
|
using var countCmd = connection.CreateCommand();
|
|
countCmd.CommandText = "SELECT COUNT(*) FROM AuditLog WHERE EventId = $id;";
|
|
countCmd.Parameters.AddWithValue("$id", sharedId.ToString());
|
|
var count = Convert.ToInt64(countCmd.ExecuteScalar());
|
|
|
|
Assert.Equal(1, count);
|
|
|
|
using var targetCmd = connection.CreateCommand();
|
|
targetCmd.CommandText = "SELECT Target FROM AuditLog WHERE EventId = $id;";
|
|
targetCmd.Parameters.AddWithValue("$id", sharedId.ToString());
|
|
Assert.Equal("first", targetCmd.ExecuteScalar() as string);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_ForcesForwardStatePending_IfNull()
|
|
{
|
|
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull));
|
|
await using var _ = writer;
|
|
|
|
var evt = NewEvent() with { ForwardState = null };
|
|
await writer.WriteAsync(evt);
|
|
|
|
using var connection = OpenVerifierConnection(dataSource);
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
|
|
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
|
|
|
|
Assert.Equal(AuditForwardState.Pending.ToString(), cmd.ExecuteScalar() as string);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN()
|
|
{
|
|
var (writer, _) = CreateWriter(nameof(ReadPendingAsync_Returns_OldestFirst_LimitedToN));
|
|
await using var _writer = 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);
|
|
}
|
|
|
|
var rows = await writer.ReadPendingAsync(limit: 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 MarkForwardedAsync_FlipsRowsToForwarded()
|
|
{
|
|
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsRowsToForwarded));
|
|
await using var _ = writer;
|
|
|
|
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
|
foreach (var id in ids)
|
|
{
|
|
await writer.WriteAsync(NewEvent(id));
|
|
}
|
|
|
|
await writer.MarkForwardedAsync(ids);
|
|
|
|
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.Forwarded.ToString()]);
|
|
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MarkForwardedAsync_NonExistentId_NoThrow()
|
|
{
|
|
var (writer, _) = CreateWriter(nameof(MarkForwardedAsync_NonExistentId_NoThrow));
|
|
await using var _writer = writer;
|
|
|
|
var phantomIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
|
|
|
|
await writer.MarkForwardedAsync(phantomIds);
|
|
// No assertion needed: the call must complete without throwing.
|
|
}
|
|
}
|