287 lines
12 KiB
C#
287 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Audit Log #23 (M3 Bundle A — Task A2) — schema + behaviour tests for the
|
|
/// site-local <see cref="OperationTrackingStore"/>. Each test uses a unique
|
|
/// shared-cache in-memory SQLite database so the store and the verifier share
|
|
/// the same store without touching disk.
|
|
/// </summary>
|
|
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<OperationTrackingStore>.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)
|
|
}
|
|
}
|