Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogQueryServiceTests.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

398 lines
15 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
public class EventLogQueryServiceTests : IDisposable
{
private readonly SiteEventLogger _eventLogger;
private readonly EventLogQueryService _queryService;
private readonly string _dbPath;
public EventLogQueryServiceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_query_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions
{
DatabasePath = _dbPath,
QueryPageSize = 500
});
_eventLogger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
_queryService = new EventLogQueryService(
_eventLogger,
options,
NullLogger<EventLogQueryService>.Instance);
}
public void Dispose()
{
_eventLogger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
private async Task SeedEvents()
{
await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Monitor", "Script timeout");
await _eventLogger.LogEventAsync("alarm", "Warning", "inst-1", "AlarmActor:TempHigh", "Alarm triggered");
await _eventLogger.LogEventAsync("deployment", "Info", "inst-2", "DeploymentManager", "Instance deployed");
await _eventLogger.LogEventAsync("connection", "Error", null, "DCL:OPC1", "Connection lost");
await _eventLogger.LogEventAsync("script", "Info", "inst-2", "ScriptActor:Calculate", "Script completed");
}
private EventLogQueryRequest MakeRequest(
string? eventType = null,
string? severity = null,
string? instanceId = null,
string? keyword = null,
string? continuationToken = null,
int pageSize = 500,
DateTimeOffset? from = null,
DateTimeOffset? to = null) =>
new(
CorrelationId: Guid.NewGuid().ToString(),
SiteId: "site-1",
From: from,
To: to,
EventType: eventType,
Severity: severity,
InstanceId: instanceId,
KeywordFilter: keyword,
ContinuationToken: continuationToken,
PageSize: pageSize,
Timestamp: DateTimeOffset.UtcNow);
[Fact]
public async Task Query_ReturnsAllEvents_WhenNoFilters()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest());
Assert.True(response.Success);
Assert.Equal(5, response.Entries.Count);
Assert.False(response.HasMore);
}
[Fact]
public async Task Query_FiltersByEventType()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(eventType: "script"));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
Assert.All(response.Entries, e => Assert.Equal("script", e.EventType));
}
[Fact]
public async Task Query_FiltersBySeverity()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(severity: "Error"));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
Assert.All(response.Entries, e => Assert.Equal("Error", e.Severity));
}
[Fact]
public async Task Query_FiltersByInstanceId()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(instanceId: "inst-1"));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
Assert.All(response.Entries, e => Assert.Equal("inst-1", e.InstanceId));
}
[Fact]
public async Task Query_KeywordSearch_MatchesMessage()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "timeout"));
Assert.True(response.Success);
Assert.Single(response.Entries);
Assert.Contains("timeout", response.Entries[0].Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Query_KeywordSearch_MatchesSource()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "AlarmActor"));
Assert.True(response.Success);
Assert.Single(response.Entries);
Assert.Contains("AlarmActor", response.Entries[0].Source);
}
[Fact]
public async Task Query_CombinesMultipleFilters()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(
eventType: "script",
severity: "Error",
instanceId: "inst-1"));
Assert.True(response.Success);
Assert.Single(response.Entries);
Assert.Equal("Script timeout", response.Entries[0].Message);
}
[Fact]
public async Task Query_Pagination_ReturnsCorrectPageSize()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(pageSize: 2));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
Assert.True(response.HasMore);
Assert.NotNull(response.ContinuationToken);
}
[Fact]
public async Task Query_Pagination_ContinuationTokenWorksCorrectly()
{
await SeedEvents();
// Get first page
var page1 = _queryService.ExecuteQuery(MakeRequest(pageSize: 2));
Assert.Equal(2, page1.Entries.Count);
Assert.True(page1.HasMore);
// Get second page using continuation token
var page2 = _queryService.ExecuteQuery(MakeRequest(
pageSize: 2,
continuationToken: page1.ContinuationToken));
Assert.Equal(2, page2.Entries.Count);
Assert.True(page2.HasMore);
// Get third page
var page3 = _queryService.ExecuteQuery(MakeRequest(
pageSize: 2,
continuationToken: page2.ContinuationToken));
Assert.Single(page3.Entries);
Assert.False(page3.HasMore);
// Verify no overlapping entries
var allIds = page1.Entries.Select(e => e.Id)
.Concat(page2.Entries.Select(e => e.Id))
.Concat(page3.Entries.Select(e => e.Id))
.ToList();
Assert.Equal(5, allIds.Distinct().Count());
}
[Fact]
public async Task Query_FiltersByTimeRange()
{
// Insert events at controlled times
var now = DateTimeOffset.UtcNow;
// Insert with a direct SQL to control timestamps
InsertEventAt(now.AddHours(-2), "script", "Info", null, "S1", "Old event");
InsertEventAt(now.AddMinutes(-30), "script", "Info", null, "S2", "Recent event");
InsertEventAt(now, "script", "Info", null, "S3", "Now event");
var response = _queryService.ExecuteQuery(MakeRequest(
from: now.AddHours(-1),
to: now.AddMinutes(1)));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
}
[Fact]
public async Task Query_FiltersByTimeRange_HandlesNonUtcOffset()
{
// SiteEventLogging-024 (re-opens -016): the store holds UTC ISO-8601 "o"
// strings (always +00:00) and compares them lexicographically. If the
// From/To bounds are stringified verbatim without UTC normalisation, a
// non-UTC DateTimeOffset from a central client sorts wrongly against the
// stored +00:00 values and the wrong rows are returned. This test seeds
// events at known UTC instants and queries with bounds expressed in a
// +05:00 offset that bracket the middle row; it FAILS against the unfixed
// code (verbatim ToString("o")) and PASSES once From/To are normalised with
// .ToUniversalTime().
// Three events at distinct, well-separated UTC instants. The recorder always
// stores UTC, so seed the rows as UTC to mirror real data.
var baseUtc = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);
InsertEventAt(baseUtc.AddHours(-2), "script", "Info", null, "EARLY", "Early event"); // 10:00 UTC
InsertEventAt(baseUtc, "script", "Info", null, "MIDDLE", "Middle event"); // 12:00 UTC
InsertEventAt(baseUtc.AddHours(2), "script", "Info", null, "LATE", "Late event"); // 14:00 UTC
// Express the SAME wall-clock window the operator intends — 11:00..13:00 UTC —
// but as a +05:00 DateTimeOffset (16:00..18:00 local). These bound only the
// MIDDLE row. With the bug, ToString("o") emits "...+05:00" which compares
// wrongly against the stored "...+00:00" rows.
var offset = TimeSpan.FromHours(5);
var fromNonUtc = new DateTimeOffset(2026, 6, 1, 16, 0, 0, offset); // == 11:00 UTC
var toNonUtc = new DateTimeOffset(2026, 6, 1, 18, 0, 0, offset); // == 13:00 UTC
var response = _queryService.ExecuteQuery(MakeRequest(from: fromNonUtc, to: toNonUtc));
Assert.True(response.Success);
// Assert on row IDENTITIES, not just the count: only the MIDDLE row falls in
// the 11:00..13:00 UTC window.
Assert.Equal(new[] { "MIDDLE" }, response.Entries.Select(e => e.Source).ToArray());
}
[Fact]
public async Task Query_EmptyResult_WhenNoMatches()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(eventType: "nonexistent"));
Assert.True(response.Success);
Assert.Empty(response.Entries);
Assert.False(response.HasMore);
Assert.Null(response.ContinuationToken);
}
[Fact]
public void Query_ReturnsCorrelationId()
{
var request = MakeRequest();
var response = _queryService.ExecuteQuery(request);
Assert.Equal(request.CorrelationId, response.CorrelationId);
Assert.Equal("site-1", response.SiteId);
}
[Fact]
public async Task Query_ReturnsAllEventLogEntryFields()
{
await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Run", "Failure", "{\"stack\":\"trace\"}");
var response = _queryService.ExecuteQuery(MakeRequest());
Assert.Single(response.Entries);
var entry = response.Entries[0];
Assert.False(string.IsNullOrWhiteSpace(entry.Id));
Assert.Equal("script", entry.EventType);
Assert.Equal("Error", entry.Severity);
Assert.Equal("inst-1", entry.InstanceId);
Assert.Equal("ScriptActor:Run", entry.Source);
Assert.Equal("Failure", entry.Message);
Assert.Equal("{\"stack\":\"trace\"}", entry.Details);
}
[Fact]
public async Task Query_KeywordSearch_TreatsUnderscoreAsLiteral_NotWildcard()
{
// SiteEventLogging-013: a SQL LIKE '_' matches any single character. A
// keyword search for a literal underscore (common in identifiers such as
// "store_and_forward") must NOT match strings that merely have any
// character in that position.
await _eventLogger.LogEventAsync("script", "Info", null, "store_and_forward", "Buffer queued");
await _eventLogger.LogEventAsync("script", "Info", null, "storeXandYforward", "Other event");
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "store_and_forward"));
Assert.True(response.Success);
Assert.Single(response.Entries);
Assert.Equal("store_and_forward", response.Entries[0].Source);
}
[Fact]
public async Task Query_KeywordSearch_TreatsPercentAsLiteral_NotWildcard()
{
// SiteEventLogging-013: a SQL LIKE '%' matches any run of characters. A
// keyword search containing a literal '%' must match only the literal.
await _eventLogger.LogEventAsync("script", "Warning", null, "ScriptActor:Run", "CPU at 90% utilisation");
await _eventLogger.LogEventAsync("script", "Warning", null, "ScriptActor:Run", "CPU at 90 then 0 utilisation");
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "90% utilisation"));
Assert.True(response.Success);
Assert.Single(response.Entries);
Assert.Contains("90% utilisation", response.Entries[0].Message);
}
[Fact]
public async Task Query_KeywordSearch_StillMatchesPlainSubstring()
{
// Escaping wildcards must not break ordinary substring search.
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "timeout"));
Assert.True(response.Success);
Assert.Single(response.Entries);
}
private void InsertEventAt(DateTimeOffset timestamp, string eventType, string severity, string? instanceId, string source, string message)
{
_eventLogger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
cmd.CommandText = """
INSERT INTO site_events (id, timestamp, event_type, severity, instance_id, source, message)
VALUES ($id, $ts, $et, $sev, $iid, $src, $msg)
""";
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("N"));
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
cmd.Parameters.AddWithValue("$et", eventType);
cmd.Parameters.AddWithValue("$sev", severity);
cmd.Parameters.AddWithValue("$iid", (object?)instanceId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$src", source);
cmd.Parameters.AddWithValue("$msg", message);
cmd.ExecuteNonQuery();
});
}
// --- SiteEventLogging-017: PageSize hard upper bound ---
[Fact]
public async Task Query_PageSize_IsClampedToMaxQueryPageSize()
{
// Tighten the cap to make the assertion deterministic without seeding 100k rows.
var dbPath = Path.Combine(Path.GetTempPath(), $"test_pagesize_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions
{
DatabasePath = dbPath,
QueryPageSize = 500,
MaxQueryPageSize = 3,
});
using var logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
var query = new EventLogQueryService(logger, options, NullLogger<EventLogQueryService>.Instance);
try
{
// Seed 5 rows but request PageSize = 100_000 — must be clamped to 3.
for (var i = 0; i < 5; i++)
await logger.LogEventAsync("script", "Info", null, $"src-{i}", $"msg-{i}");
var response = query.ExecuteQuery(new EventLogQueryRequest(
CorrelationId: Guid.NewGuid().ToString(),
SiteId: "site-1",
From: null, To: null,
EventType: null, Severity: null, InstanceId: null, KeywordFilter: null,
ContinuationToken: null,
PageSize: 100_000,
Timestamp: DateTimeOffset.UtcNow));
Assert.Equal(3, response.Entries.Count);
Assert.True(response.HasMore);
}
finally
{
logger.Dispose();
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}
}