docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// Regression tests for SiteEventLogging-010: previously untested behaviours —
/// Regression tests for Previously untested behaviours —
/// the query service error path and the recorder's disposed-state semantics.
/// </summary>
public class EventLogCoverageTests : IDisposable
@@ -13,12 +13,14 @@ public class EventLogCoverageTests : IDisposable
private readonly string _dbPath;
private readonly TestLocalDb _localDb;
/// <summary>Initializes a new instance of the <c>EventLogCoverageTests</c> class.</summary>
public EventLogCoverageTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_coverage_{Guid.NewGuid()}.db");
_localDb = TestLocalDb.Create(_dbPath);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_localDb.Dispose();
@@ -46,6 +48,7 @@ public class EventLogCoverageTests : IDisposable
PageSize: 500,
Timestamp: DateTimeOffset.UtcNow);
/// <summary>Verifies that execute query returns failure response when database unavailable.</summary>
[Fact]
public void ExecuteQuery_ReturnsFailureResponse_WhenDatabaseUnavailable()
{
@@ -70,10 +73,12 @@ public class EventLogCoverageTests : IDisposable
Assert.Equal("corr-err", response.CorrelationId);
}
/// <summary>Verifies that log event async after dispose faults task not reports success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_AfterDispose_FaultsTask_NotReportsSuccess()
{
// SiteEventLogging-012: when the logger has been disposed the event cannot
// When the logger has been disposed the event cannot
// be persisted. The returned Task must FAULT (not complete successfully) so
// an awaiting caller can distinguish a dropped audit event from a written
// one. Per the XML doc contract, the Task "faults if the write fails".
@@ -86,6 +91,7 @@ public class EventLogCoverageTests : IDisposable
() => task.WaitAsync(TimeSpan.FromSeconds(5)));
}
/// <summary>Verifies that dispose is idempotent.</summary>
[Fact]
public void Dispose_IsIdempotent()
{
@@ -95,10 +101,12 @@ public class EventLogCoverageTests : IDisposable
logger.Dispose();
}
/// <summary>Verifies that log event async enqueued then disposed faults task when write cannot complete.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_EnqueuedThenDisposed_FaultsTask_WhenWriteCannotComplete()
{
// SiteEventLogging-012, second path: an event enqueued onto the background
// Second path: an event enqueued onto the background
// writer just before disposal must NOT be reported as persisted if the
// writer's WithConnection returns false (logger disposed mid-drain). We
// flood the queue and dispose immediately; any event whose write did not
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// Regression tests for SiteEventLogging-010: the actor message contract of
/// Regression tests for the actor message contract of
/// <see cref="EventLogHandlerActor"/> was previously untested.
/// </summary>
public class EventLogHandlerActorTests : TestKit
@@ -14,11 +14,15 @@ public class EventLogHandlerActorTests : TestKit
private sealed class FakeQueryService : IEventLogQueryService
{
private readonly Func<EventLogQueryRequest, EventLogQueryResponse> _handler;
/// <summary>Gets the most recent query request received.</summary>
public EventLogQueryRequest? LastRequest { get; private set; }
/// <summary>Initializes the fake with a handler that produces query responses.</summary>
/// <param name="handler">The function that answers each query request.</param>
public FakeQueryService(Func<EventLogQueryRequest, EventLogQueryResponse> handler)
=> _handler = handler;
/// <inheritdoc />
public EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request)
{
LastRequest = request;
@@ -49,6 +53,7 @@ public class EventLogHandlerActorTests : TestKit
ErrorMessage: success ? null : "boom",
Timestamp: DateTimeOffset.UtcNow);
/// <summary>Verifies that the actor replies to the sender with the query response.</summary>
[Fact]
public void Actor_RepliesToSender_WithQueryResponse()
{
@@ -65,6 +70,7 @@ public class EventLogHandlerActorTests : TestKit
Assert.Same(request, fake.LastRequest);
}
/// <summary>Verifies that the actor passes a query-service error response on to the sender.</summary>
[Fact]
public void Actor_PropagatesQueryServiceErrorResponse_ToSender()
{
@@ -13,7 +13,7 @@ public class EventLogPurgeServiceTests : IDisposable
private readonly SiteEventLogOptions _options;
/// <summary>
/// SiteEventLogging-023: stop flag for the concurrent stress test. Declared as
/// Stop flag for the concurrent stress test. Declared as
/// a <c>volatile</c> field so every writer thread observes the main thread's
/// `_stop = true` write without depending on JIT/runtime quirks. A plain
/// <c>bool</c> local would be legal-cached in a register inside the tight
@@ -21,6 +21,7 @@ public class EventLogPurgeServiceTests : IDisposable
/// </summary>
private volatile bool _stop;
/// <summary>Initializes a new instance of the <see cref="EventLogPurgeServiceTests"/> class with a temporary LocalDb file.</summary>
public EventLogPurgeServiceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_purge_{Guid.NewGuid()}.db");
@@ -37,6 +38,7 @@ public class EventLogPurgeServiceTests : IDisposable
_localDb.Db);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_eventLogger.Dispose();
@@ -85,6 +87,7 @@ public class EventLogPurgeServiceTests : IDisposable
});
}
/// <summary>Verifies that the retention purge deletes events older than the retention window.</summary>
[Fact]
public void PurgeByRetention_DeletesOldEvents()
{
@@ -98,6 +101,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(1, GetEventCount());
}
/// <summary>Verifies that the retention purge keeps recent events.</summary>
[Fact]
public void PurgeByRetention_KeepsRecentEvents()
{
@@ -111,10 +115,11 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(3, GetEventCount());
}
/// <summary>Verifies that the retention purge deletes every expired row across multiple batch slices when the backlog exceeds one batch.</summary>
[Fact]
public void PurgeByRetention_OverBatchSize_DeletesAllExpiredRowsAcrossSlices()
{
// WP1.7: the retention purge must slice a large expired backlog into
// The retention purge must slice a large expired backlog into
// bounded DELETE batches (1000 rows/iteration, mirroring the storage-cap
// purge a few lines below in the same file) rather than one unbounded
// DELETE. Seed more than one slice's worth of expired rows plus a
@@ -145,6 +150,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(2, GetEventCount());
}
/// <summary>Verifies that the storage-cap purge deletes the oldest events when the database is over the cap.</summary>
[Fact]
public void PurgeByStorageCap_DeletesOldestWhenOverCap()
{
@@ -169,6 +175,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(0, GetEventCount());
}
/// <summary>Verifies that the database size query returns a positive value.</summary>
[Fact]
public void GetDatabaseSizeBytes_ReturnsPositiveValue()
{
@@ -241,11 +248,11 @@ public class EventLogPurgeServiceTests : IDisposable
});
}
/// <summary>Verifies that the storage-cap purge stops once under the cap and does not empty the table.</summary>
[Fact]
public void PurgeByStorageCap_StopsWhenUnderCap_DoesNotEmptyTable()
{
// Regression test for SiteEventLogging-001 / -002:
// a realistic non-zero cap must trim the oldest events to the budget,
// Regression test: a realistic non-zero cap must trim the oldest events to the budget,
// not delete the entire table.
InsertBulkEvents(3000);
@@ -275,10 +282,11 @@ public class EventLogPurgeServiceTests : IDisposable
$"Database size {finalSize} must be at or below cap {finalCapBytes} after purge.");
}
/// <summary>Verifies that the storage-cap purge removes the oldest events first.</summary>
[Fact]
public void PurgeByStorageCap_RemovesOldestEventsFirst()
{
// Regression test for SiteEventLogging-002: only the oldest events should be
// Regression test: only the oldest events should be
// removed when trimming to the cap.
//
// "Oldest" used to mean "lowest autoincrement id". Since LocalDb Phase 1 the id
@@ -320,10 +328,12 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(1L, newestPresent);
}
/// <summary>Verifies that starting the service does not block on the initial purge.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_DoesNotBlock_OnTheInitialPurge()
{
// SiteEventLogging-014 (re-triaged): on .NET 8+ BackgroundService runs
// On .NET 8+ BackgroundService runs
// ExecuteAsync on a thread-pool thread — the synchronous prelude (the
// initial RunPurge()) does NOT execute on the host startup thread, so
// StartAsync returns promptly and host startup / the /health/ready gate is
@@ -356,10 +366,12 @@ public class EventLogPurgeServiceTests : IDisposable
await purge.StopAsync(CancellationToken.None);
}
/// <summary>Verifies that ExecuteAsync yields before running the initial purge synchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteAsync_YieldsBeforeRunningTheInitialPurgeSynchronously()
{
// arch-review 2026-08-19 P4-5 — timing-independent companion to
// Timing-independent companion to
// StartAsync_DoesNotBlock_OnTheInitialPurge above.
//
// That test times BackgroundService.StartAsync as a whole and passes even on the
@@ -368,7 +380,7 @@ public class EventLogPurgeServiceTests : IDisposable
// never blocks regardless of whether ExecuteAsync yields first. Verified empirically
// against this repo's actual net10.0 target (a BackgroundService whose ExecuteAsync
// does Thread.Sleep(1500) before its first await still returns from StartAsync in
// ~2 ms). So P4-5's literal "StartAsync blocks" symptom does not reproduce here, and
// ~2 ms). So the literal "StartAsync blocks" symptom does not reproduce here, and
// a wall-clock test cannot prove the fix.
//
// What CAN be proven without any timing is the ExecuteAsync-level contract itself:
@@ -432,12 +444,13 @@ public class EventLogPurgeServiceTests : IDisposable
}
}
/// <summary>Verifies that a throwing active-node check is logged as inactive rather than active.</summary>
[Fact]
public void RunPurge_ActiveNodeCheckThrows_TickLogsAsInactive_NotActive()
{
// arch-review 2026-08-19 P4-5: a throwing isActive predicate must fail CLOSED
// A throwing isActive predicate must fail CLOSED
// (logged as inactive) rather than silently claiming "active". The predicate no
// longer gates any purge work (P4-4 — every node purges its own file every tick
// longer gates any purge work (every node purges its own file every tick
// regardless), so a wrong value here cannot skip work; but an operator correlating
// this line against the real active-node state must not be misled into thinking the
// check actually succeeded and reported active.
@@ -459,10 +472,12 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Contains("active=False", tickLine.Message, StringComparison.Ordinal);
}
/// <summary>Verifies that concurrent writes during a storage-cap purge do not corrupt the connection.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeByStorageCap_ConcurrentWritesDoNotCorruptConnection()
{
// Regression test for SiteEventLogging-003: purge running on a background
// Regression test: purge running on a background
// thread while events are recorded on other threads must not throw
// "DataReader already open" / "connection busy" from a shared connection.
InsertBulkEvents(2000);
@@ -477,7 +492,7 @@ public class EventLogPurgeServiceTests : IDisposable
};
var exceptions = new System.Collections.Concurrent.ConcurrentBag<Exception>();
// SiteEventLogging-023: must be volatile so the writer threads observe the
// Must be volatile so the writer threads observe the
// main thread's `stop = true` flip after the purge task completes. Without
// it, a release-mode JIT is permitted to cache the `stop = false` read in
// a register inside the tight `while (!stop)` loop and never see the flip,
@@ -514,15 +529,16 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Empty(exceptions);
}
// ── arch-review 2026-08-19 P4-4: purge runs on every node, not just the active one ──
// (supersedes the old SiteEventLogging-019 "active-node-only gate" — DataConnectionActor
// ── Purge runs on every node, not just the active one ──
// (supersedes the old "active-node-only gate" — DataConnectionActor
// writes site_events on both site nodes, so a standby that skipped its own purge would
// grow its file unbounded on an unreplicated site.)
/// <summary>Verifies that a standby node still runs the purge.</summary>
[Fact]
public void RunPurge_OnStandbyNode_StillRunsThePurge()
{
// P4-4 regression: before the fix, a standby (isActiveNode returning false) early-exited
// Regression: before the fix, a standby (isActiveNode returning false) early-exited
// the purge tick entirely, leaving this well-past-retention row in place forever on an
// unreplicated standby. The gate is gone — the standby purges its own file exactly like
// the active node.
@@ -535,6 +551,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(0, GetEventCount());
}
/// <summary>Verifies that an active node runs the retention purge.</summary>
[Fact]
public void RunPurge_OnActiveNode_RunsTheRetentionPurge()
{
@@ -549,11 +566,12 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(1, GetEventCount());
}
/// <summary>Verifies that the purge still runs when no active-node check is supplied.</summary>
[Fact]
public void RunPurge_WithNullCheck_StillRuns()
{
// When no active-node check is supplied (the default for non-clustered hosts and
// pre-existing tests), the service runs the purge — unaffected by P4-4 since the
// pre-existing tests), the service runs the purge — unaffected by the every-node purge since the
// check was never gating anything for this caller either.
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31));
@@ -573,6 +591,7 @@ public class EventLogPurgeServiceTests : IDisposable
/// </summary>
private const string BacklogNoteFragment = "transient LocalDb oplog backlog";
/// <summary>Verifies that the retention purge on a replicated node logs the oplog backlog note.</summary>
[Fact]
public void PurgeByRetention_OnReplicatedNode_LogsTheOplogBacklogNote()
{
@@ -592,6 +611,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(LogLevel.Information, note.Level);
}
/// <summary>Verifies that the retention purge on an unreplicated node does not log the backlog note.</summary>
[Fact]
public void PurgeByRetention_OnUnreplicatedNode_DoesNotLogTheNote()
{
@@ -608,6 +628,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.DoesNotContain(logger.Entries, e => e.Message.Contains(BacklogNoteFragment, StringComparison.Ordinal));
}
/// <summary>Verifies that the retention purge does not log the backlog note when no rows were deleted.</summary>
[Fact]
public void PurgeByRetention_WithNoRowsDeleted_DoesNotLogTheNote()
{
@@ -624,6 +645,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.DoesNotContain(logger.Entries, e => e.Message.Contains(BacklogNoteFragment, StringComparison.Ordinal));
}
/// <summary>Verifies that the storage-cap purge on a replicated node logs the oplog backlog note.</summary>
[Fact]
public void PurgeByStorageCap_OnReplicatedNode_LogsTheOplogBacklogNote()
{
@@ -650,6 +672,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(LogLevel.Information, note.Level);
}
/// <summary>Verifies that the backlog note is logged once per tick when both purge phases delete rows.</summary>
[Fact]
public void RunPurge_WhenBothPhasesDeleteRows_LogsTheBacklogNoteOnceForTheWholeTick()
{
@@ -691,6 +714,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Contains($"Purged {expiredRows + recentRows} site_events rows", note.Message, StringComparison.Ordinal);
}
/// <summary>Verifies that the purge still runs and swallows the error when the replication check throws.</summary>
[Fact]
public void RunPurge_WhenReplicationCheckThrows_StillPurgesAndSwallows()
{
@@ -757,6 +781,7 @@ public class EventLogPurgeServiceTests : IDisposable
PurgeCatchUpInterval = TimeSpan.FromSeconds(1),
};
/// <summary>Verifies that the retention purge stops at the per-tick row ceiling.</summary>
[Fact]
public void PurgeByRetention_StopsAtThePerTickCeiling()
{
@@ -773,6 +798,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(1500, GetEventCount());
}
/// <summary>Verifies that the retention purge with a ceiling converges across successive ticks.</summary>
[Fact]
public void PurgeByRetention_WithCeiling_ConvergesAcrossSuccessiveTicks()
{
@@ -792,6 +818,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(1, GetEventCount());
}
/// <summary>Verifies that an expired set that lands exactly on the ceiling is not counted as a ceiling hit.</summary>
[Fact]
public void PurgeByRetention_ExpiredSetLandingExactlyOnTheCeiling_IsNotACeilingHit()
{
@@ -806,10 +833,11 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(0, GetEventCount());
}
/// <summary>Verifies that a zero MaxPurgedRowsPerTick restores unbounded purges.</summary>
[Fact]
public void MaxPurgedRowsPerTick_Zero_RestoresUnboundedPurges()
{
// The documented escape hatch: 0 means unlimited, i.e. byte-for-byte the pre-row-34
// The documented escape hatch: 0 means unlimited, i.e. byte-for-byte the pre-ceiling
// behaviour, for an operator who would rather take one long burst than a paced one.
InsertExpiredEvents(2500);
@@ -819,6 +847,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(0, GetEventCount());
}
/// <summary>Verifies that hitting the ceiling logs the ceiling line and increments the counter.</summary>
[Fact]
public void RunPurge_HittingTheCeiling_LogsTheCeilingLineAndCountsIt()
{
@@ -838,6 +867,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(1, probe.Total);
}
/// <summary>Verifies that repeated ceiling hits rate-limit the log line but not the counter.</summary>
[Fact]
public void RunPurge_RepeatedCeilingHits_RateLimitTheLogLineButNotTheCounter()
{
@@ -855,6 +885,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.Equal(2, probe.Total);
}
/// <summary>Verifies that the storage-cap purge shares the per-tick budget with the retention purge.</summary>
[Fact]
public void PurgeByStorageCap_SharesTheTickBudgetWithRetention()
{
@@ -875,6 +906,7 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("exceeds cap", StringComparison.Ordinal));
}
/// <summary>Verifies that the storage-cap purge with a ceiling stops at the budget and asks for a catch-up tick.</summary>
[Fact]
public void PurgeByStorageCap_WithCeiling_StopsAtTheBudgetAndAsksForCatchUp()
{
@@ -904,6 +936,8 @@ public class EventLogPurgeServiceTests : IDisposable
private readonly System.Diagnostics.Metrics.MeterListener _listener = new();
private long _total;
/// <summary>Initializes a new instance of the <see cref="CounterProbe"/> class listening to the named instrument.</summary>
/// <param name="instrumentName">The name of the counter instrument to listen to.</param>
public CounterProbe(string instrumentName)
{
_listener.InstrumentPublished = (instrument, listener) =>
@@ -919,6 +953,7 @@ public class EventLogPurgeServiceTests : IDisposable
/// <summary>Measurements recorded since construction.</summary>
public long Total => Interlocked.Read(ref _total);
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _listener.Dispose();
}
@@ -929,12 +964,27 @@ public class EventLogPurgeServiceTests : IDisposable
/// </summary>
private sealed class CapturingLogger : ILogger<EventLogPurgeService>
{
/// <summary>Gets the captured log entries in the order they were written.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
/// <summary>Starts no scope; the fake does not track scopes.</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state, ignored.</param>
/// <returns>No scope token; the fake does not track scopes.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level being queried.</param>
/// <returns>Always <see langword="true"/>.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The severity of the entry.</param>
/// <param name="eventId">The event id of the entry.</param>
/// <param name="state">The state object being logged.</param>
/// <param name="exception">The exception associated with the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message text.</param>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
@@ -11,6 +11,7 @@ public class EventLogQueryServiceTests : IDisposable
private readonly TestLocalDb _localDb;
private readonly string _dbPath;
/// <summary>Initializes the test class with a temporary LocalDb-backed event logger and query service.</summary>
public EventLogQueryServiceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_query_{Guid.NewGuid()}.db");
@@ -27,6 +28,7 @@ public class EventLogQueryServiceTests : IDisposable
NullLogger<EventLogQueryService>.Instance);
}
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose()
{
_eventLogger.Dispose();
@@ -66,6 +68,8 @@ public class EventLogQueryServiceTests : IDisposable
PageSize: pageSize,
Timestamp: DateTimeOffset.UtcNow);
/// <summary>Verifies that a query with no filters returns all events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_ReturnsAllEvents_WhenNoFilters()
{
@@ -78,6 +82,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.False(response.HasMore);
}
/// <summary>Verifies that a query filters events by event type.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_FiltersByEventType()
{
@@ -90,6 +96,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.All(response.Entries, e => Assert.Equal("script", e.EventType));
}
/// <summary>Verifies that a query filters events by severity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_FiltersBySeverity()
{
@@ -102,6 +110,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.All(response.Entries, e => Assert.Equal("Error", e.Severity));
}
/// <summary>Verifies that a query filters events by instance id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_FiltersByInstanceId()
{
@@ -114,6 +124,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.All(response.Entries, e => Assert.Equal("inst-1", e.InstanceId));
}
/// <summary>Verifies that a keyword search matches the event message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_KeywordSearch_MatchesMessage()
{
@@ -126,6 +138,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Contains("timeout", response.Entries[0].Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that a keyword search matches the event source.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_KeywordSearch_MatchesSource()
{
@@ -138,6 +152,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Contains("AlarmActor", response.Entries[0].Source);
}
/// <summary>Verifies that a query combines multiple filters.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_CombinesMultipleFilters()
{
@@ -153,6 +169,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Equal("Script timeout", response.Entries[0].Message);
}
/// <summary>Verifies that pagination returns the requested page size.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_Pagination_ReturnsCorrectPageSize()
{
@@ -166,6 +184,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.NotNull(response.ContinuationToken);
}
/// <summary>Verifies that the continuation token retrieves the next page correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_Pagination_ContinuationTokenWorksCorrectly()
{
@@ -198,6 +218,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Equal(5, allIds.Distinct().Count());
}
/// <summary>Verifies that a query filters events by time range.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_FiltersByTimeRange()
{
@@ -217,10 +239,12 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Equal(2, response.Entries.Count);
}
/// <summary>Verifies that a time range filter normalises non-UTC offsets correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_FiltersByTimeRange_HandlesNonUtcOffset()
{
// SiteEventLogging-024 (re-opens -016): the store holds UTC ISO-8601 "o"
// 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
@@ -253,6 +277,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Equal(new[] { "MIDDLE" }, response.Entries.Select(e => e.Source).ToArray());
}
/// <summary>Verifies that a query with no matches returns an empty result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_EmptyResult_WhenNoMatches()
{
@@ -266,6 +292,7 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Null(response.ContinuationToken);
}
/// <summary>Verifies that a query returns the correlation id of each event.</summary>
[Fact]
public void Query_ReturnsCorrelationId()
{
@@ -276,6 +303,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Equal("site-1", response.SiteId);
}
/// <summary>Verifies that a query returns every field of the event log entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_ReturnsAllEventLogEntryFields()
{
@@ -294,10 +323,12 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Equal("{\"stack\":\"trace\"}", entry.Details);
}
/// <summary>Verifies that a keyword search treats an underscore as a literal rather than a wildcard.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_KeywordSearch_TreatsUnderscoreAsLiteral_NotWildcard()
{
// SiteEventLogging-013: a SQL LIKE '_' matches any single character. A
// 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.
@@ -311,10 +342,12 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Equal("store_and_forward", response.Entries[0].Source);
}
/// <summary>Verifies that a keyword search treats a percent sign as a literal rather than a wildcard.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_KeywordSearch_TreatsPercentAsLiteral_NotWildcard()
{
// SiteEventLogging-013: a SQL LIKE '%' matches any run of characters. A
// 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");
@@ -326,6 +359,8 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Contains("90% utilisation", response.Entries[0].Message);
}
/// <summary>Verifies that a keyword search still matches a plain substring.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_KeywordSearch_StillMatchesPlainSubstring()
{
@@ -358,8 +393,10 @@ public class EventLogQueryServiceTests : IDisposable
});
}
// --- SiteEventLogging-017: PageSize hard upper bound ---
// --- PageSize hard upper bound ---
/// <summary>Verifies that a requested page size is clamped to the maximum query page size.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_PageSize_IsClampedToMaxQueryPageSize()
{
@@ -33,6 +33,8 @@ public class EventLogStorageCapScopeTests : IDisposable
private readonly TestLocalDb _localDb;
private readonly string _dbPath;
/// <summary>Initializes a new instance of the <c>EventLogStorageCapScopeTests</c> class.</summary>
/// <param name="output">The output.</param>
public EventLogStorageCapScopeTests(ITestOutputHelper output)
{
_output = output;
@@ -44,6 +46,7 @@ public class EventLogStorageCapScopeTests : IDisposable
_localDb.Db);
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
_eventLogger.Dispose();
@@ -128,6 +131,7 @@ public class EventLogStorageCapScopeTests : IDisposable
return (long)cmd.ExecuteScalar()!;
});
/// <summary>Verifies that the estimated event-log size excludes other tables in the same database file.</summary>
[Fact]
public void EstimatedSize_ExcludesOtherTablesInTheSameFile()
{
@@ -154,6 +158,7 @@ public class EventLogStorageCapScopeTests : IDisposable
Assert.Equal(eventsOnly, eventsAfterBallast);
}
/// <summary>Verifies that the storage-cap purge does not trim an under-cap event log when other tables in the same file are large.</summary>
[Fact]
public void PurgeByStorageCap_WithLargeBallastInTheSameFile_DoesNotTrimAnUnderCapEventLog()
{
@@ -179,6 +184,7 @@ public class EventLogStorageCapScopeTests : IDisposable
Assert.Equal(4000, EventCount());
}
/// <summary>Verifies that the storage-cap purge still enforces the cap on events without emptying the table when other tables in the same file are large.</summary>
[Fact]
public void PurgeByStorageCap_WithLargeBallastInTheSameFile_StillEnforcesTheCapOnEventsAndDoesNotEmptyTheTable()
{
@@ -217,6 +223,7 @@ public class EventLogStorageCapScopeTests : IDisposable
// ── size-model accuracy ──
/// <summary>Gets the workloads value.</summary>
public static TheoryData<string, int, int, double> Workloads => new()
{
// name, rows, details length (0 = no details column), details on every row
@@ -225,6 +232,11 @@ public class EventLogStorageCapScopeTests : IDisposable
{ "wide rows, 2KB details", 3000, 2048, 1.0 },
};
/// <summary>Verifies that the site-events size estimate tracks the real on-disk footprint across workloads.</summary>
/// <param name="name">The name.</param>
/// <param name="rows">The rows.</param>
/// <param name="detailsLength">The details length.</param>
/// <param name="detailsFraction">The details fraction.</param>
[Theory]
[MemberData(nameof(Workloads))]
public void EstimateSiteEventsSize_TracksTheRealFootprint(string name, int rows, int detailsLength, double detailsFraction)
@@ -292,13 +304,16 @@ public class EventLogStorageCapScopeTests : IDisposable
long? frozenRowCount)
: EventLogPurgeService(eventLogger, options, logger)
{
/// <inheritdoc />
internal override EventLogPurgeService.SiteEventsSizeEstimate EstimateSiteEventsSize() =>
estimate ?? base.EstimateSiteEventsSize();
/// <inheritdoc />
internal override long GetSiteEventsRowCount() =>
frozenRowCount ?? base.GetSiteEventsRowCount();
}
/// <summary>Verifies that the storage-cap purge deletes nothing and logs a warning when the row width cannot be sampled.</summary>
[Fact]
public void PurgeByStorageCap_WhenRowWidthCannotBeSampled_DeletesNothingAndWarns()
{
@@ -322,6 +337,7 @@ public class EventLogStorageCapScopeTests : IDisposable
e.Level == LogLevel.Warning && e.Message.Contains("row width could not be sampled", StringComparison.Ordinal));
}
/// <summary>Verifies that the storage-cap purge stops rather than emptying the log when the table stops shrinking.</summary>
[Fact]
public void PurgeByStorageCap_WhenTheTableStopsShrinking_StopsRatherThanEmptyingTheLog()
{
@@ -351,12 +367,27 @@ public class EventLogStorageCapScopeTests : IDisposable
private sealed class CapturingLogger : ILogger<EventLogPurgeService>
{
/// <summary>Gets the entries value.</summary>
public List<LogEntry> Entries { get; } = [];
/// <summary>Returns no scope; the fake does not track scopes.</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state (ignored).</param>
/// <returns>Always null.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level to check.</param>
/// <returns>Always true.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The severity of the entry.</param>
/// <param name="eventId">The event identifier.</param>
/// <param name="state">The state object to format.</param>
/// <param name="exception">The exception associated with the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message.</param>
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// Regression tests for SiteEventLogging-006: the schema must index the columns the
/// Regression tests: the schema must index the columns the
/// query service filters on so common queries do not full-scan a 1 GB database.
/// </summary>
public class SchemaIndexTests : IDisposable
@@ -15,6 +15,7 @@ public class SchemaIndexTests : IDisposable
private readonly string _dbPath;
private readonly TestLocalDb _localDb;
/// <summary>Initializes a new instance of the <c>SchemaIndexTests</c> class.</summary>
public SchemaIndexTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_index_{Guid.NewGuid()}.db");
@@ -26,6 +27,7 @@ public class SchemaIndexTests : IDisposable
_verifyConnection.Open();
}
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose()
{
_verifyConnection.Dispose();
@@ -34,6 +36,7 @@ public class SchemaIndexTests : IDisposable
TestLocalDb.DeleteFiles(_dbPath);
}
/// <summary>Verifies that schema: has index on severity.</summary>
[Fact]
public void Schema_HasIndexOnSeverity()
{
@@ -47,6 +50,8 @@ public class SchemaIndexTests : IDisposable
Assert.Contains("idx_events_severity", indexes);
}
/// <summary>Verifies that severity filtered query, when uses index, not full scan.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SeverityFilteredQuery_UsesIndex_NotFullScan()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// Regression tests for SiteEventLogging-007: the purge and query services must not
/// Regression tests: the purge and query services must not
/// downcast an injected <see cref="ISiteEventLogger"/> to the concrete type. They now
/// depend on the concrete <see cref="SiteEventLogger"/> directly, and DI must resolve
/// the recorder, query service, and purge service to a single shared instance.
@@ -17,17 +17,20 @@ public class ServiceWiringTests : IDisposable
private readonly string _dbPath;
private ServiceProvider? _provider;
/// <summary>Initializes the test class with a service provider backed by a temporary database.</summary>
public ServiceWiringTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_wiring_{Guid.NewGuid()}.db");
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_provider?.Dispose();
TestLocalDb.DeleteFiles(_dbPath);
}
/// <summary>Verifies that registering site event logging resolves all services and shares one recorder instance.</summary>
[Fact]
public void AddSiteEventLogging_ResolvesAllServices_SharingOneRecorderInstance()
{
@@ -61,6 +64,7 @@ public class ServiceWiringTests : IDisposable
Assert.Contains(hosted, h => h is EventLogPurgeService);
}
/// <summary>Verifies that the purge and query services accept the concrete recorder without downcasting.</summary>
[Fact]
public void PurgeAndQueryServices_AcceptConcreteRecorder_WithoutDowncast()
{
@@ -14,6 +14,7 @@ public class SiteEventLogOptionsValidatorTests
private static ValidateOptionsResult Validate(SiteEventLogOptions options) =>
new SiteEventLogOptionsValidator().Validate(Options.DefaultName, options);
/// <summary>Verifies that the default options are valid.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -21,6 +22,7 @@ public class SiteEventLogOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that zero retention days is rejected.</summary>
[Fact]
public void ZeroRetentionDays_IsRejected()
{
@@ -30,6 +32,7 @@ public class SiteEventLogOptionsValidatorTests
Assert.Contains("RetentionDays", result.FailureMessage);
}
/// <summary>Verifies that a zero purge interval is rejected.</summary>
[Fact]
public void ZeroPurgeInterval_IsRejected()
{
@@ -39,6 +42,7 @@ public class SiteEventLogOptionsValidatorTests
Assert.Contains("PurgeInterval", result.FailureMessage);
}
/// <summary>Verifies that a maximum query page size below the default page size is rejected.</summary>
[Fact]
public void MaxQueryPageSize_BelowQueryPageSize_IsRejected()
{
@@ -50,6 +54,7 @@ public class SiteEventLogOptionsValidatorTests
// ── Register row 34: the per-tick purge ceiling ──
/// <summary>Verifies that a negative maximum of purged rows per tick is rejected.</summary>
[Fact]
public void NegativeMaxPurgedRowsPerTick_IsRejected()
{
@@ -61,6 +66,7 @@ public class SiteEventLogOptionsValidatorTests
Assert.Contains("MaxPurgedRowsPerTick", result.FailureMessage);
}
/// <summary>Verifies that a zero maximum of purged rows per tick (unlimited) is accepted.</summary>
[Fact]
public void ZeroMaxPurgedRowsPerTick_IsAccepted()
{
@@ -70,6 +76,7 @@ public class SiteEventLogOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a zero purge catch-up interval is rejected.</summary>
[Fact]
public void ZeroPurgeCatchUpInterval_IsRejected()
{
@@ -80,6 +87,7 @@ public class SiteEventLogOptionsValidatorTests
Assert.Contains("PurgeCatchUpInterval", result.FailureMessage);
}
/// <summary>Verifies that a purge catch-up interval longer than the purge interval is rejected.</summary>
[Fact]
public void PurgeCatchUpInterval_LongerThanPurgeInterval_IsRejected()
{
@@ -3,7 +3,7 @@ using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// LocalDb Phase 1 (Task 2) — the <c>site_events</c> DDL is extracted out of
/// The <c>site_events</c> DDL is extracted out of
/// <see cref="SiteEventLogger"/> so the Host's <c>AddZbLocalDb</c> onReady callback can
/// create it in the consolidated site database.
/// <para>
@@ -39,6 +39,7 @@ public class SiteEventLogSchemaTests
return cols;
}
/// <summary>Verifies that applying the schema creates the table with the expected columns.</summary>
[Fact]
public void Apply_CreatesTableWithExpectedColumns()
{
@@ -51,6 +52,7 @@ public class SiteEventLogSchemaTests
TableInfo(conn, "site_events").Select(c => c.Name));
}
/// <summary>Verifies that the id column is a text primary key rather than an autoincrement integer.</summary>
[Fact]
public void Apply_IdIsTextPrimaryKey_NotAutoincrement()
{
@@ -71,6 +73,7 @@ public class SiteEventLogSchemaTests
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
}
/// <summary>Verifies that inserts that omit the text id are rejected.</summary>
[Fact]
public void Apply_RejectsIntegerIdInserts()
{
@@ -79,7 +82,7 @@ public class SiteEventLogSchemaTests
SiteEventLogSchema.Apply(conn);
// A TEXT PK with no AUTOINCREMENT means an INSERT omitting id is an error,
// which is what forces the writer to mint one (Task 5a).
// which is what forces the writer to mint one.
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO site_events (timestamp, event_type, severity, source, message)
@@ -88,6 +91,7 @@ public class SiteEventLogSchemaTests
Assert.Throws<SqliteException>(() => cmd.ExecuteNonQuery());
}
/// <summary>Verifies that the schema has no blob columns.</summary>
[Fact]
public void Apply_HasNoBlobColumns()
{
@@ -100,6 +104,7 @@ public class SiteEventLogSchemaTests
c => c.Type.Contains("BLOB", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that applying the schema creates the expected indexes.</summary>
[Fact]
public void Apply_CreatesExpectedIndexes()
{
@@ -120,6 +125,7 @@ public class SiteEventLogSchemaTests
indexes);
}
/// <summary>Verifies that applying the schema repeatedly is safe.</summary>
[Fact]
public void Apply_IsIdempotent()
{
@@ -6,8 +6,8 @@ using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// Regression tests for SiteEventLogging-005 (synchronous I/O on caller thread)
/// and SiteEventLogging-008 (write failures silently swallowed).
/// Regression tests for non-blocking logging (synchronous I/O on the caller thread)
/// and surfaced write failures (failures silently swallowed).
/// </summary>
public class SiteEventLoggerAsyncTests : IDisposable
{
@@ -15,6 +15,7 @@ public class SiteEventLoggerAsyncTests : IDisposable
private readonly string _dbPath;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with a temporary LocalDb-backed event logger.</summary>
public SiteEventLoggerAsyncTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_async_{Guid.NewGuid()}.db");
@@ -23,6 +24,7 @@ public class SiteEventLoggerAsyncTests : IDisposable
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_logger.Dispose();
@@ -30,10 +32,12 @@ public class SiteEventLoggerAsyncTests : IDisposable
TestLocalDb.DeleteFiles(_dbPath);
}
/// <summary>Verifies that logging an event does not block the caller when the write is slow.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_DoesNotBlockCaller_WhenWriteIsSlow()
{
// SiteEventLogging-005: LogEventAsync must not perform the SQLite write
// LogEventAsync must not perform the SQLite write
// inline on the caller's thread. We hold the recorder busy on a long
// database operation from one thread, then time how long it takes the
// caller of LogEventAsync to get control back. If recording is offloaded
@@ -71,6 +75,8 @@ public class SiteEventLoggerAsyncTests : IDisposable
await recordTask.WaitAsync(TimeSpan.FromSeconds(10));
}
/// <summary>Verifies that the logging task completes only after the event is persisted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_TaskCompletes_AfterEventIsPersisted()
{
@@ -86,10 +92,12 @@ public class SiteEventLoggerAsyncTests : IDisposable
Assert.Equal(1, count);
}
/// <summary>Verifies that a write error faults the logging task and increments the failure count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_FaultsTask_AndCountsFailure_OnWriteError()
{
// SiteEventLogging-008: a write failure must not be silently swallowed.
// A write failure must not be silently swallowed.
// The returned Task must fault and the failure counter must increment so
// Health Monitoring can surface a logging outage.
using var failingConnection = new SqliteConnection("Data Source=:memory:");
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// WP1.7 (arch-review remediation, 2026-08-14): <see cref="SiteEventLogger"/>'s
/// The <see cref="SiteEventLogger"/>'s
/// background writer loop must drain its bounded channel into batched transactions
/// (up to <see cref="SiteEventLogger.WriteBatchSize"/> events per commit) instead of
/// committing one transaction per event — mirroring the reference shape in
@@ -17,6 +17,7 @@ public class SiteEventLoggerBatchingTests : IDisposable
private readonly string _dbPath;
private readonly TestLocalDb _localDb;
/// <summary>Initializes a new instance of the <see cref="SiteEventLoggerBatchingTests"/> class with a temporary LocalDb file.</summary>
public SiteEventLoggerBatchingTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_batch_{Guid.NewGuid()}.db");
@@ -25,6 +26,7 @@ public class SiteEventLoggerBatchingTests : IDisposable
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_logger.Dispose();
@@ -32,6 +34,8 @@ public class SiteEventLoggerBatchingTests : IDisposable
TestLocalDb.DeleteFiles(_dbPath);
}
/// <summary>Verifies that logging a single event commits exactly one batch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_SingleEvent_CommitsOneBatch()
{
@@ -40,6 +44,8 @@ public class SiteEventLoggerBatchingTests : IDisposable
Assert.Equal(1, _logger.CommittedBatchCount);
}
/// <summary>Verifies that a burst of queued events commits far fewer batches than events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProcessWriteQueue_BurstOfQueuedEvents_CommitsFarFewerBatchesThanEvents()
{
@@ -102,6 +108,8 @@ public class SiteEventLoggerBatchingTests : IDisposable
Assert.Equal(burstCount, count);
}
/// <summary>Verifies that a batch failure faults every queued event so none is silently dropped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProcessWriteQueue_BatchFailure_FaultsEveryQueuedEvent_NoneSilentlyDropped()
{
@@ -156,6 +164,8 @@ public class SiteEventLoggerBatchingTests : IDisposable
$"Expected FailedWriteCount to account for all {burstCount} events in the rolled-back batch, got {_logger.FailedWriteCount}.");
}
/// <summary>Verifies that a poison row in the middle of a batch fails alone while the other rows are written.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProcessWriteQueue_PoisonRowMidBatch_OtherRowsLand_AndOnlyThePoisonRowIsCountedFailed()
{
@@ -223,6 +233,8 @@ public class SiteEventLoggerBatchingTests : IDisposable
Assert.Equal(burstCount - 1, landed);
}
/// <summary>Verifies that disposing the logger persists events that are still queued at shutdown.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_PersistsEventsStillQueuedAtShutdown()
{
@@ -11,6 +11,7 @@ public class SiteEventLoggerTests : IDisposable
private readonly string _dbPath;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with a temporary LocalDb-backed logger.</summary>
public SiteEventLoggerTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_events_{Guid.NewGuid()}.db");
@@ -23,6 +24,7 @@ public class SiteEventLoggerTests : IDisposable
_verifyConnection.Open();
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
_verifyConnection.Dispose();
@@ -31,6 +33,8 @@ public class SiteEventLoggerTests : IDisposable
TestLocalDb.DeleteFiles(_dbPath);
}
/// <summary>Verifies that logging an event inserts a record.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_InsertsRecord()
{
@@ -42,6 +46,8 @@ public class SiteEventLoggerTests : IDisposable
Assert.Equal(1, count);
}
/// <summary>Verifies that logging an event stores all of its fields.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_StoresAllFields()
{
@@ -59,6 +65,8 @@ public class SiteEventLoggerTests : IDisposable
Assert.Equal("{\"value\":95}", reader.GetString(5));
}
/// <summary>Verifies that the nullable fields may be left empty.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_NullableFieldsAllowed()
{
@@ -72,6 +80,8 @@ public class SiteEventLoggerTests : IDisposable
Assert.True(reader.IsDBNull(1));
}
/// <summary>Verifies that the stored timestamp is an ISO 8601 UTC value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_StoresIso8601UtcTimestamp()
{
@@ -84,6 +94,8 @@ public class SiteEventLoggerTests : IDisposable
Assert.Equal(TimeSpan.Zero, parsed.Offset);
}
/// <summary>Verifies that an empty event type throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_ThrowsOnEmptyEventType()
{
@@ -91,6 +103,8 @@ public class SiteEventLoggerTests : IDisposable
_logger.LogEventAsync("", "Info", null, "Source", "Message"));
}
/// <summary>Verifies that an empty severity throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_ThrowsOnEmptySeverity()
{
@@ -98,6 +112,8 @@ public class SiteEventLoggerTests : IDisposable
_logger.LogEventAsync("script", "", null, "Source", "Message"));
}
/// <summary>Verifies that an empty source throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_ThrowsOnEmptySource()
{
@@ -105,6 +121,8 @@ public class SiteEventLoggerTests : IDisposable
_logger.LogEventAsync("script", "Info", null, "", "Message"));
}
/// <summary>Verifies that an empty message throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_ThrowsOnEmptyMessage()
{
@@ -112,6 +130,8 @@ public class SiteEventLoggerTests : IDisposable
_logger.LogEventAsync("script", "Info", null, "Source", ""));
}
/// <summary>Verifies that multiple events each get a distinct application-minted id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogEventAsync_MultipleEvents_GetDistinctApplicationMintedIds()
{
@@ -139,6 +159,8 @@ public class SiteEventLoggerTests : IDisposable
$"Event id '{id}' is not a GUID in 'N' format."));
}
/// <summary>Verifies that all event types are accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AllEventTypes_Accepted()
{
@@ -154,8 +176,11 @@ public class SiteEventLoggerTests : IDisposable
Assert.Equal(6, count);
}
// --- SiteEventLogging-020: severity validation against the closed set ---
// --- Severity validation against the closed set ---
/// <summary>Verifies that an unknown severity throws.</summary>
/// <param name="badSeverity">The bad severity.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("info")] // wrong casing
[InlineData("warn")] // abbreviation
@@ -170,6 +195,9 @@ public class SiteEventLoggerTests : IDisposable
Assert.Contains("Info, Warning, Error", ex.Message);
}
/// <summary>Verifies that every documented severity is accepted.</summary>
/// <param name="severity">The severity.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("Info")]
[InlineData("Warning")]
@@ -43,6 +43,7 @@ public class SiteEventsSchemaCalibrationTests
return values;
}
/// <summary>Verifies that the modelled columns exactly match the sampled octet-length sum.</summary>
[Fact]
public void Columns_MatchTheSampledOctetLengthSum_Exactly()
{
@@ -59,6 +60,7 @@ public class SiteEventsSchemaCalibrationTests
$"{RecalibrationNotice} Actual columns: {string.Join(", ", columns)}");
}
/// <summary>Verifies that the table has exactly the four indexes the model charges for, plus the primary-key autoindex.</summary>
[Fact]
public void Indexes_AreExactlyTheFourTheModelCharges_PlusThePkAutoindex()
{
@@ -82,6 +84,9 @@ public class SiteEventsSchemaCalibrationTests
$"{RecalibrationNotice} Actual indexes: {string.Join(", ", indexes)}");
}
/// <summary>Verifies that each index covers exactly the column the sampler sums.</summary>
/// <param name="indexName">The index name.</param>
/// <param name="expectedColumn">The column the index is expected to cover.</param>
[Theory]
[InlineData("idx_events_timestamp", "timestamp")]
[InlineData("idx_events_type", "event_type")]
@@ -23,6 +23,7 @@ public class SqliteCapabilityTests
return connection;
}
/// <summary>Verifies that the bundled SQLite build has no dbstat table, so the per-table page count has to be modelled.</summary>
[Fact]
public void DbStat_IsNotAvailable_SoThePerTablePageCountMustBeModelled()
{
@@ -64,6 +65,7 @@ public class SqliteCapabilityTests
Assert.Contains("dbstat", ex.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that octet_length is available, so row width is sampled in bytes rather than characters.</summary>
[Fact]
public void OctetLength_IsAvailable_SoRowWidthIsSampledInBytesNotCharacters()
{
@@ -38,6 +38,8 @@ public sealed class TestLocalDb : IDisposable
public ILocalDb Db { get; }
/// <summary>Opens a local database at <paramref name="databasePath"/>.</summary>
/// <param name="databasePath">The filesystem path of the SQLite database file.</param>
/// <returns>A wrapper owning the opened database and its service provider.</returns>
public static TestLocalDb Create(string databasePath)
{
var configuration = new ConfigurationBuilder()
@@ -58,6 +60,7 @@ public sealed class TestLocalDb : IDisposable
/// Deletes <paramref name="databasePath"/> and its WAL sidecars. Call only after the
/// owning <see cref="TestLocalDb"/> is disposed — the master connection anchors the WAL.
/// </summary>
/// <param name="databasePath">The filesystem path of the SQLite database file whose files are deleted.</param>
public static void DeleteFiles(string databasePath)
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
@@ -66,5 +69,6 @@ public sealed class TestLocalDb : IDisposable
}
}
/// <summary>Disposes the underlying service provider.</summary>
public void Dispose() => _provider.Dispose();
}