Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerTests.cs
T
Joseph Doherty 727fa48cba feat(localdb): move site_events to application-minted GUID ids
Tasks 2 + 5a-writer + 5b of the LocalDb Phase 1 plan, landed together because
they are one indivisible change: the schema, the writer that fills it, and every
consumer that assumed the old id semantics.

WHY the id changes. Site pairs will replicate site_events with last-writer-wins on
the primary key. With INTEGER PRIMARY KEY AUTOINCREMENT both nodes independently
mint 1, 2, 3... for unrelated events, so sync would treat them as the same row and
silently overwrite. A GUID makes the event log a pure union across the pair.

Task 2 - schema extracted so the Host's AddZbLocalDb onReady can create the tables
before RegisterReplicated installs capture triggers (pre-registration rows are
never captured):
  - new OperationTrackingSchema.Apply / SiteEventLogSchema.Apply, plain
    Microsoft.Data.Sqlite, no LocalDb dependency
  - both stores delegate their InitializeSchema to them (idempotent, so a
    directly-constructed store still works)
  - OperationTracking is unchanged - it already had a TEXT PK and replicates as-is

Task 5a - SiteEventLogger mints Guid.NewGuid("N") per event and inserts it.

Task 5b - three consumers assumed a monotonic integer id. All three move to
timestamp ordering; leaving any one behind would be a live bug:
  - EventLogQueryService: "id > $afterId" would return an ARBITRARY subset of a
    GUID-keyed table and SILENTLY DROP ROWS from page-through. Now a composite
    (timestamp, id) keyset cursor with an opaque string token; timestamps are not
    unique, so id is the tie-break that guarantees exactly-once paging.
  - EventLogPurgeService: "ORDER BY id ASC LIMIT 1000" would delete a RANDOM batch
    instead of the oldest. Now orders by timestamp.
  - EventLogEntry.Id and both ContinuationTokens: long -> string.

WIRE COMPATIBILITY. Those DTOs cross the site<->central Akka boundary
(SiteCommunicationActor -> CommunicationService -> ManagementActor / CentralUI).
No rolling-upgrade shim is needed because both sides ship in the same deployable
and the rig redeploys as a unit. Checked: no Akka serializer binding pins these
types by name. A stale numeric token degrades to "start from the beginning"
(a visible repeat) rather than throwing or losing rows.

Tests: the two that encoded the old semantics were rewritten to guard the new
invariant rather than deleted - uniqueness instead of monotonicity, and
oldest-purged-first keyed on timestamp. That second test also exposed a latent
weakness: the bulk seed stamped every row with the same UtcNow, so "oldest" was
never actually well-defined; rows now get distinct increasing timestamps, kept
inside the retention window so the retention purge does not eat them first.

Verified:
  dotnet build ZB.MOM.WW.ScadaBridge.slnx -> 0 Error(s), 0 Warning(s)
  SiteEventLogging.Tests -> 70 passed (12 new schema tests, red-first)
  CentralUI.Tests        -> 925 passed
  Commons.Tests          -> 684 passed
  SiteRuntime.Tests      -> 529 passed, 1 pre-existing flaky failure
                            (InstanceActorChildAttributeRaceTests - passes 3/3 in
                            isolation with and without this change)

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-19 03:15:17 -04:00

184 lines
7.1 KiB
C#

using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
public class SiteEventLoggerTests : IDisposable
{
private readonly SiteEventLogger _logger;
private readonly SqliteConnection _verifyConnection;
private readonly string _dbPath;
public SiteEventLoggerTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_events_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath });
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
// Separate connection for verification queries
_verifyConnection = new SqliteConnection($"Data Source={_dbPath}");
_verifyConnection.Open();
}
public void Dispose()
{
_verifyConnection.Dispose();
_logger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
[Fact]
public async Task LogEventAsync_InsertsRecord()
{
await _logger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Monitor", "Script failed", "{\"stack\":\"...\"}");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM site_events";
var count = (long)cmd.ExecuteScalar()!;
Assert.Equal(1, count);
}
[Fact]
public async Task LogEventAsync_StoresAllFields()
{
await _logger.LogEventAsync("alarm", "Warning", "inst-2", "AlarmActor:TempHigh", "Alarm triggered", "{\"value\":95}");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT event_type, severity, instance_id, source, message, details FROM site_events LIMIT 1";
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
Assert.Equal("alarm", reader.GetString(0));
Assert.Equal("Warning", reader.GetString(1));
Assert.Equal("inst-2", reader.GetString(2));
Assert.Equal("AlarmActor:TempHigh", reader.GetString(3));
Assert.Equal("Alarm triggered", reader.GetString(4));
Assert.Equal("{\"value\":95}", reader.GetString(5));
}
[Fact]
public async Task LogEventAsync_NullableFieldsAllowed()
{
await _logger.LogEventAsync("deployment", "Info", null, "DeploymentManager", "Deployed instance");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT instance_id, details FROM site_events LIMIT 1";
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
Assert.True(reader.IsDBNull(0));
Assert.True(reader.IsDBNull(1));
}
[Fact]
public async Task LogEventAsync_StoresIso8601UtcTimestamp()
{
await _logger.LogEventAsync("connection", "Info", null, "DCL", "Connected");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT timestamp FROM site_events LIMIT 1";
var ts = (string)cmd.ExecuteScalar()!;
var parsed = DateTimeOffset.Parse(ts);
Assert.Equal(TimeSpan.Zero, parsed.Offset);
}
[Fact]
public async Task LogEventAsync_ThrowsOnEmptyEventType()
{
await Assert.ThrowsAsync<ArgumentException>(() =>
_logger.LogEventAsync("", "Info", null, "Source", "Message"));
}
[Fact]
public async Task LogEventAsync_ThrowsOnEmptySeverity()
{
await Assert.ThrowsAsync<ArgumentException>(() =>
_logger.LogEventAsync("script", "", null, "Source", "Message"));
}
[Fact]
public async Task LogEventAsync_ThrowsOnEmptySource()
{
await Assert.ThrowsAsync<ArgumentException>(() =>
_logger.LogEventAsync("script", "Info", null, "", "Message"));
}
[Fact]
public async Task LogEventAsync_ThrowsOnEmptyMessage()
{
await Assert.ThrowsAsync<ArgumentException>(() =>
_logger.LogEventAsync("script", "Info", null, "Source", ""));
}
[Fact]
public async Task LogEventAsync_MultipleEvents_GetDistinctApplicationMintedIds()
{
// Replaces LogEventAsync_MultipleEvents_AutoIncrementIds. Ids are no longer
// server-minted autoincrement integers: under LocalDb replication the site pair
// converges by last-writer-wins on the primary key, so both nodes issuing 1, 2,
// 3... for unrelated events would silently overwrite each other on sync.
//
// The invariant is therefore uniqueness, NOT monotonicity — ordering is carried
// by the timestamp column, which is what the query and purge paths sort on.
await _logger.LogEventAsync("script", "Info", null, "S1", "First");
await _logger.LogEventAsync("script", "Info", null, "S2", "Second");
await _logger.LogEventAsync("script", "Info", null, "S3", "Third");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT id FROM site_events ORDER BY timestamp";
using var reader = cmd.ExecuteReader();
var ids = new List<string>();
while (reader.Read()) ids.Add(reader.GetString(0));
Assert.Equal(3, ids.Count);
Assert.All(ids, id => Assert.False(string.IsNullOrWhiteSpace(id)));
Assert.Equal(3, ids.Distinct(StringComparer.Ordinal).Count());
Assert.All(ids, id => Assert.True(Guid.TryParseExact(id, "N", out _),
$"Event id '{id}' is not a GUID in 'N' format."));
}
[Fact]
public async Task AllEventTypes_Accepted()
{
var types = new[] { "script", "alarm", "deployment", "connection", "store_and_forward", "instance_lifecycle" };
foreach (var t in types)
{
await _logger.LogEventAsync(t, "Info", null, "Test", $"Event type: {t}");
}
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT COUNT(DISTINCT event_type) FROM site_events";
var count = (long)cmd.ExecuteScalar()!;
Assert.Equal(6, count);
}
// --- SiteEventLogging-020: severity validation against the closed set ---
[Theory]
[InlineData("info")] // wrong casing
[InlineData("warn")] // abbreviation
[InlineData("ERROR")] // wrong casing
[InlineData("Debug")] // not in set
[InlineData("Critical")] // not in set
public async Task LogEventAsync_ThrowsOnUnknownSeverity(string badSeverity)
{
var ex = await Assert.ThrowsAsync<ArgumentException>(
() => _logger.LogEventAsync("script", badSeverity, null, "Source", "Message"));
Assert.Contains(badSeverity, ex.Message);
Assert.Contains("Info, Warning, Error", ex.Message);
}
[Theory]
[InlineData("Info")]
[InlineData("Warning")]
[InlineData("Error")]
public async Task LogEventAsync_AcceptsAllDocumentedSeverities(string severity)
{
await _logger.LogEventAsync("script", severity, null, "Source", "Message");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT severity FROM site_events";
var stored = (string)cmd.ExecuteScalar()!;
Assert.Equal(severity, stored);
}
}