using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Enums; using ScadaLink.SiteRuntime.Tracking; namespace ScadaLink.SiteRuntime.Tests.Tracking; /// /// Audit Log #23 (M3 Bundle A — Task A2) — schema + behaviour tests for the /// site-local . Each test uses a unique /// shared-cache in-memory SQLite database so the store and the verifier share /// the same store without touching disk. /// public class OperationTrackingStoreTests { private static (OperationTrackingStore store, string dataSource) CreateStore( string testName) { var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared"; var connectionString = $"Data Source={dataSource};Cache=Shared"; var options = new OperationTrackingOptions { ConnectionString = connectionString, }; var store = new OperationTrackingStore( Options.Create(options), NullLogger.Instance); return (store, dataSource); } private static SqliteConnection OpenVerifierConnection(string dataSource) { var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); connection.Open(); return connection; } [Fact] public void Constructor_CreatesOperationTracking_SchemaOnFirstUse() { var (store, dataSource) = CreateStore(nameof(Constructor_CreatesOperationTracking_SchemaOnFirstUse)); using (store) { using var connection = OpenVerifierConnection(dataSource); using var cmd = connection.CreateCommand(); cmd.CommandText = "PRAGMA table_info(OperationTracking);"; using var reader = cmd.ExecuteReader(); var columns = new List<(string Name, int Pk, int NotNull)>(); while (reader.Read()) { columns.Add((reader.GetString(1), reader.GetInt32(5), reader.GetInt32(3))); } var expected = new[] { "TrackedOperationId", "Kind", "TargetSummary", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc", "SourceInstanceId", "SourceScript", }; Assert.Equal( expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n)); var pkColumns = columns.Where(c => c.Pk > 0).Select(c => c.Name).ToList(); Assert.Single(pkColumns); Assert.Equal("TrackedOperationId", pkColumns[0]); } } [Fact] public async Task RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero() { var (store, dataSource) = CreateStore(nameof(RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero)); await using var _ = store; var id = TrackedOperationId.New(); await store.RecordEnqueueAsync( id, kind: nameof(AuditKind.ApiCallCached), targetSummary: "ERP.GetOrder", sourceInstanceId: "Plant.Pump42", sourceScript: "ScriptActor:OnTick"); var snapshot = await store.GetStatusAsync(id); Assert.NotNull(snapshot); Assert.Equal(id, snapshot!.Id); Assert.Equal(nameof(AuditKind.ApiCallCached), snapshot.Kind); Assert.Equal("ERP.GetOrder", snapshot.TargetSummary); Assert.Equal(nameof(AuditStatus.Submitted), snapshot.Status); Assert.Equal(0, snapshot.RetryCount); Assert.Null(snapshot.LastError); Assert.Null(snapshot.HttpStatus); Assert.Null(snapshot.TerminalAtUtc); Assert.Equal("Plant.Pump42", snapshot.SourceInstanceId); Assert.Equal("ScriptActor:OnTick", snapshot.SourceScript); Assert.Equal(DateTimeKind.Utc, snapshot.CreatedAtUtc.Kind); Assert.Equal(DateTimeKind.Utc, snapshot.UpdatedAtUtc.Kind); } [Fact] public async Task RecordEnqueueAsync_Duplicate_IsNoOp_FirstWriteWins() { var (store, _) = CreateStore(nameof(RecordEnqueueAsync_Duplicate_IsNoOp_FirstWriteWins)); await using var _store = store; var id = TrackedOperationId.New(); await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", "Plant.Pump42", "ScriptActor:OnTick"); await store.RecordEnqueueAsync(id, "ApiCallCached", "OtherTarget", "Other.Instance", "ScriptActor:Other"); var snapshot = await store.GetStatusAsync(id); Assert.NotNull(snapshot); // First-write-wins: the second enqueue is ignored — Target/Source stay first. Assert.Equal("ERP.GetOrder", snapshot!.TargetSummary); Assert.Equal("Plant.Pump42", snapshot.SourceInstanceId); Assert.Equal("ScriptActor:OnTick", snapshot.SourceScript); Assert.Equal(nameof(AuditStatus.Submitted), snapshot.Status); Assert.Equal(0, snapshot.RetryCount); } [Fact] public async Task RecordAttemptAsync_AdvancesStatusAndRetryCount_OnNonTerminalRow() { var (store, _) = CreateStore(nameof(RecordAttemptAsync_AdvancesStatusAndRetryCount_OnNonTerminalRow)); await using var _store = store; var id = TrackedOperationId.New(); await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null); await store.RecordAttemptAsync( id, status: nameof(AuditStatus.Attempted), retryCount: 1, lastError: "HTTP 503 from ERP", httpStatus: 503); var snapshot = await store.GetStatusAsync(id); Assert.NotNull(snapshot); Assert.Equal(nameof(AuditStatus.Attempted), snapshot!.Status); Assert.Equal(1, snapshot.RetryCount); Assert.Equal("HTTP 503 from ERP", snapshot.LastError); Assert.Equal(503, snapshot.HttpStatus); Assert.Null(snapshot.TerminalAtUtc); // UpdatedAtUtc advances past CreatedAtUtc. Assert.True(snapshot.UpdatedAtUtc >= snapshot.CreatedAtUtc); } [Fact] public async Task RecordAttemptAsync_OnTerminalRow_IsNoOp() { var (store, _) = CreateStore(nameof(RecordAttemptAsync_OnTerminalRow_IsNoOp)); await using var _store = store; var id = TrackedOperationId.New(); await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null); await store.RecordTerminalAsync( id, status: nameof(AuditStatus.Delivered), lastError: null, httpStatus: 200); var terminalSnapshot = await store.GetStatusAsync(id); Assert.NotNull(terminalSnapshot); Assert.NotNull(terminalSnapshot!.TerminalAtUtc); // Late attempt telemetry must NOT overwrite the terminal row. await store.RecordAttemptAsync( id, status: nameof(AuditStatus.Attempted), retryCount: 5, lastError: "late attempt", httpStatus: 500); var afterLate = await store.GetStatusAsync(id); Assert.NotNull(afterLate); Assert.Equal(nameof(AuditStatus.Delivered), afterLate!.Status); Assert.Equal(0, afterLate.RetryCount); Assert.Null(afterLate.LastError); Assert.Equal(200, afterLate.HttpStatus); Assert.NotNull(afterLate.TerminalAtUtc); } [Fact] public async Task RecordTerminalAsync_FlipsToTerminal_WithTerminalAtUtcSet() { var (store, _) = CreateStore(nameof(RecordTerminalAsync_FlipsToTerminal_WithTerminalAtUtcSet)); await using var _store = store; var id = TrackedOperationId.New(); await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null); var beforeTerminal = DateTime.UtcNow; await store.RecordTerminalAsync( id, status: nameof(AuditStatus.Parked), lastError: "HTTP 503 (max retries)", httpStatus: 503); var snapshot = await store.GetStatusAsync(id); Assert.NotNull(snapshot); Assert.Equal(nameof(AuditStatus.Parked), snapshot!.Status); Assert.NotNull(snapshot.TerminalAtUtc); Assert.Equal(DateTimeKind.Utc, snapshot.TerminalAtUtc!.Value.Kind); Assert.True(snapshot.TerminalAtUtc >= beforeTerminal.AddSeconds(-1)); Assert.Equal("HTTP 503 (max retries)", snapshot.LastError); Assert.Equal(503, snapshot.HttpStatus); } [Fact] public async Task GetStatusAsync_Unknown_ReturnsNull() { var (store, _) = CreateStore(nameof(GetStatusAsync_Unknown_ReturnsNull)); await using var _store = store; var unknown = TrackedOperationId.New(); var snapshot = await store.GetStatusAsync(unknown); Assert.Null(snapshot); } [Fact] public async Task GetStatusAsync_ReturnsLatestSnapshot_AfterMultipleAttempts() { var (store, _) = CreateStore(nameof(GetStatusAsync_ReturnsLatestSnapshot_AfterMultipleAttempts)); await using var _store = store; var id = TrackedOperationId.New(); await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null); await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 1, "first failure", 503); await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 2, "second failure", 503); await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 3, "third failure", 504); var snapshot = await store.GetStatusAsync(id); Assert.NotNull(snapshot); Assert.Equal(3, snapshot!.RetryCount); Assert.Equal("third failure", snapshot.LastError); Assert.Equal(504, snapshot.HttpStatus); } [Fact] public async Task PurgeTerminalAsync_RemovesOldTerminalRows_KeepsRecent_KeepsNonTerminal() { var (store, dataSource) = CreateStore(nameof(PurgeTerminalAsync_RemovesOldTerminalRows_KeepsRecent_KeepsNonTerminal)); await using var _store = store; // Three rows: // (a) terminal, old → should be purged // (b) terminal, fresh → should be kept // (c) non-terminal, ancient CreatedAt → should be kept (no TerminalAtUtc) var aId = TrackedOperationId.New(); var bId = TrackedOperationId.New(); var cId = TrackedOperationId.New(); await store.RecordEnqueueAsync(aId, "ApiCallCached", "A", null, null); await store.RecordEnqueueAsync(bId, "ApiCallCached", "B", null, null); await store.RecordEnqueueAsync(cId, "ApiCallCached", "C", null, null); await store.RecordTerminalAsync(aId, nameof(AuditStatus.Delivered), null, 200); await store.RecordTerminalAsync(bId, nameof(AuditStatus.Delivered), null, 200); // Backdate the (a) row's TerminalAtUtc to 30 days ago via a direct UPDATE // — RecordTerminalAsync stamps DateTime.UtcNow which we cannot inject. // The verifier connection shares the same in-memory store thanks to // mode=memory&cache=shared. using (var connection = OpenVerifierConnection(dataSource)) using (var cmd = connection.CreateCommand()) { cmd.CommandText = "UPDATE OperationTracking SET TerminalAtUtc = $old WHERE TrackedOperationId = $id;"; cmd.Parameters.AddWithValue("$old", DateTime.UtcNow.AddDays(-30).ToString("o")); cmd.Parameters.AddWithValue("$id", aId.ToString()); cmd.ExecuteNonQuery(); } // Purge anything terminal older than 7 days. var threshold = DateTime.UtcNow.AddDays(-7); await store.PurgeTerminalAsync(threshold); Assert.Null(await store.GetStatusAsync(aId)); // purged Assert.NotNull(await store.GetStatusAsync(bId)); // kept (recent terminal) Assert.NotNull(await store.GetStatusAsync(cId)); // kept (non-terminal) } }