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
@@ -19,9 +19,9 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>'s M3 combined-
/// telemetry dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
/// as the M1 + M2 repository tests so the actor exercises real
/// Tests for <see cref="AuditLogIngestActor"/>'s combined-telemetry
/// dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
/// as the audit-log and site-call repository tests so the actor exercises real
/// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> +
/// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL
/// database. The transaction commits or rolls back inside one
@@ -31,6 +31,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
/// <param name="fixture">The shared MSSQL migration fixture.</param>
public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
@@ -125,6 +127,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
serviceProvider,
NullLogger<AuditLogIngestActor>.Instance)));
/// <summary>Verifies that one cached-telemetry packet writes both an audit row and a site-call row and acknowledges the event id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId()
{
@@ -156,6 +160,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(siteCall.Status, siteCallRow!.Status);
}
/// <summary>Verifies that a duplicate event id with an unchanged status is a no-op that leaves the row counts unchanged and still acknowledges the id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId()
{
@@ -195,6 +201,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(1, siteCallCount);
}
/// <summary>Verifies that a duplicate event id carrying an advanced site-call status updates the existing site-call row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall()
{
@@ -206,7 +214,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
// 1st packet — Submitted (audit EventId #1, SiteCalls Status=Submitted).
// 1st packet — Submitted (first audit EventId, SiteCalls Status=Submitted).
var (auditSubmit, siteCallSubmit) = NewEntry(
siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted);
actor.Tell(
@@ -214,7 +222,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// 2nd packet — Attempted with retry count 1 (audit EventId #2,
// 2nd packet — Attempted with retry count 1 (second audit EventId,
// SiteCalls Status=Attempted — monotonic upsert wins). Same
// TrackedOperationId throughout.
var (auditAttempt, siteCallAttempt) = NewEntry(
@@ -242,6 +250,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(1, siteCallRow.RetryCount);
}
/// <summary>Verifies that when the site-call upsert throws after the audit insert succeeds, both writes are rolled back and no orphan audit row remains.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow()
{
@@ -273,6 +283,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Null(siteCallRow);
}
/// <summary>Verifies that five cached-telemetry packets are each persisted separately and all are acknowledged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked()
{
@@ -303,6 +315,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(5, siteCallCount);
}
/// <summary>Verifies that a packet failing after an earlier packet succeeded leaves the first one committed and lets the batch continue.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues()
{
@@ -348,7 +362,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
}
/// <summary>
/// arch-review 2026-08-19 P5-1: the per-entry fallback loop (used when the
/// The per-entry fallback loop (used when the
/// batch fast path fails) must run under its own short
/// <see cref="AuditLogIngestActor.IngestFallbackBudget"/>, not an unbounded
/// wait — otherwise a single slow/wedged downstream call can freeze this
@@ -368,6 +382,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
/// <c>ExpectMsg</c> below would time out and fail, which is the fail-unfixed
/// proof for this fix.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_CachedTelemetry_PerEntryFallbackHangs_BudgetBoundsWait_RemainderSkipped()
{
@@ -430,38 +445,51 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
/// </summary>
private sealed class BatchFailingAuditRepo : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly IAuditLogRepository _inner;
/// <summary>Initializes the double with the real repository that every non-failing member delegates to.</summary>
/// <param name="inner">The real repository to delegate to.</param>
public BatchFailingAuditRepo(IAuditLogRepository inner) { _inner = inner; }
/// <inheritdoc />
public Task<int> InsertManyIfNotExistsAsync(
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated batch ingest failure — forces the per-entry fallback");
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
_inner.InsertIfNotExistsAsync(evt, ct);
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
_inner.GetExecutionTreeAsync(executionId, ct);
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
@@ -471,15 +499,17 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
/// Test double — <see cref="UpsertAsync"/> hangs on
/// <c>Task.Delay(Timeout.InfiniteTimeSpan, ct)</c> until the caller's token
/// cancels, recording the call count and the token it was handed. Used to
/// prove the per-entry fallback loop's own budget bounds the wait (arch-review
/// 2026-08-19 P5-1) rather than blocking forever on a wedged downstream call.
/// prove the per-entry fallback loop's own budget bounds the wait rather blocking forever on a wedged downstream call.
/// </summary>
private sealed class HangingSiteCallRepo : ISiteCallAuditRepository
{
private int _callCount;
/// <summary>Gets the number of times <see cref="UpsertAsync"/> has been invoked.</summary>
public int CallCount => _callCount;
/// <summary>Gets the cancellation token most recently passed to <see cref="UpsertAsync"/>.</summary>
public CancellationToken? ReceivedToken { get; private set; }
/// <inheritdoc />
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
Interlocked.Increment(ref _callCount);
@@ -487,19 +517,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
}
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new NotSupportedException();
@@ -513,22 +549,31 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
/// <summary>Initializes the double with the real repository that read members delegate to.</summary>
/// <param name="inner">The real repository to delegate to.</param>
public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; }
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated SiteCalls upsert failure");
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
@@ -543,11 +588,15 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
{
private readonly ISiteCallAuditRepository _inner;
private readonly TrackedOperationId _poisonId;
/// <summary>Initializes the double with the real repository and the id whose upsert should fail.</summary>
/// <param name="inner">The real repository to delegate to.</param>
/// <param name="poisonId">The tracked operation id whose upsert throws.</param>
public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall.TrackedOperationId == _poisonId)
@@ -556,19 +605,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
}
return _inner.UpsertAsync(siteCall, ct);
}
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>. Uses the same
/// <see cref="MsSqlMigrationFixture"/> as the M1 repository tests so the actor
/// <see cref="MsSqlMigrationFixture"/> as the repository tests so the actor
/// exercises real <see cref="AuditLogRepository.InsertIfNotExistsAsync"/>
/// against a partitioned MSSQL schema (the only way to verify the
/// IngestedAtUtc stamp + duplicate-key idempotency end to end).
@@ -27,6 +27,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
/// <param name="fixture">The shared MSSQL migration fixture.</param>
public AuditLogIngestActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
@@ -61,6 +63,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
NullLogger<AuditLogIngestActor>.Instance)));
}
/// <summary>Verifies that a batch of five events calls the repository five times and acknowledges all five.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5()
{
@@ -87,6 +91,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.Equal(5, rows.Count);
}
/// <summary>Verifies that a batch containing an already-existing event acknowledges every event without inserting a duplicate.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert()
{
@@ -126,12 +132,14 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.Equal(3, count);
}
/// <summary>Verifies that duplicate event ids within one packet produce a single row and are all acknowledged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_DuplicateEventIds_WithinOnePacket_ProduceOneRow_AndAreAllAcked()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// WP2.2 writes each packet as ONE set-based statement, whose anti-semi-join
// The repository writes each packet as ONE set-based statement, whose anti-semi-join
// only sees already-COMMITTED rows. Two copies of an EventId inside one
// packet would therefore both pass it and collide on the clustered PK,
// taking the whole packet down — the repository de-duplicates first.
@@ -161,6 +169,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.Equal(2, rows.Count);
}
/// <summary>Verifies that sending the same packet twice is idempotent across packets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets()
{
@@ -194,6 +204,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.Equal(6, rows.Count);
}
/// <summary>Verifies that overlapping packets insert only the new rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows()
{
@@ -225,6 +237,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.Equal(7, rows.Count);
}
/// <summary>Verifies that the actor stamps IngestedAtUtc before inserting an event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_Sets_IngestedAtUtc_Before_Insert()
{
@@ -257,6 +271,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
});
}
/// <summary>Verifies that when the repository throws for one event, the other four are still persisted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted()
{
@@ -289,10 +305,12 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.DoesNotContain(rows, r => r.EventId == poisonId);
}
/// <summary>Verifies that when repository resolution throws, the actor survives, replies with an empty acknowledgement, and counts the failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Receive_WhenRepositoryResolutionThrows_ActorSurvives_RepliesEmpty_CountsFailure()
{
// AuditLog-017 (covers the AuditLog-014 guard): the production ctor resolves the
// The production ctor resolves the
// scoped repository per message. If scope creation / repository resolution throws
// (transient DI or DbContext-factory fault, pooled-context init, a resolution race
// during host churn), the outer guard must keep the singleton ALIVE, increment the
@@ -343,6 +361,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
/// source, so "not equal" IS "a fresh CTS", and a fresh CTS cannot be
/// pre-cancelled by the batch.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Receive_WhenBatchFails_PerRowFallbackUsesAFreshToken()
{
@@ -371,7 +390,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
/// <summary>Counts how many times the guard's catch surfaced a write failure.</summary>
private sealed class CountingFailureCounter : ICentralAuditWriteFailureCounter
{
/// <summary>Gets the number of failures counted so far.</summary>
public int Count { get; private set; }
/// <inheritdoc />
public void Increment() => Count++;
}
@@ -382,13 +403,17 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
/// </summary>
private sealed class TokenRecordingRepository : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <summary>Gets the cancellation token handed to the set-based batch insert.</summary>
public CancellationToken? BatchToken { get; private set; }
/// <summary>Gets the cancellation tokens handed to each per-row insert.</summary>
public List<CancellationToken> RowTokens { get; } = new();
/// <inheritdoc />
public Task<int> InsertManyIfNotExistsAsync(
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default)
{
@@ -396,40 +421,49 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
throw new OperationCanceledException("simulated ingest-budget expiry", ct);
}
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
RowTokens.Add(ct);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
throw new NotSupportedException();
}
@@ -441,18 +475,23 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
/// </summary>
private sealed class ThrowingRepository : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly IAuditLogRepository _inner;
private readonly Guid _poisonId;
/// <summary>Initializes the double with an inner repository and the id of the event that must fail.</summary>
/// <param name="inner">The real repository to delegate to.</param>
/// <param name="poisonId">The event id for which inserts throw.</param>
public ThrowingRepository(IAuditLogRepository inner, Guid poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt.EventId == _poisonId)
@@ -462,33 +501,41 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
return _inner.InsertIfNotExistsAsync(evt, ct);
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
_inner.GetExecutionTreeAsync(executionId, ct);
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct);
}
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// </summary>
public class AuditLogPartitionMaintenanceOptionsValidatorTests
{
/// <summary>Verifies that the default options pass validation.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -17,6 +18,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests
Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded);
}
/// <summary>Verifies that a zero interval fails validation and names the interval property.</summary>
[Fact]
public void ZeroIntervalSeconds_Fails()
{
@@ -28,6 +30,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal));
}
/// <summary>Verifies that a zero look-ahead fails validation and names the look-ahead property.</summary>
[Fact]
public void ZeroLookaheadMonths_Fails()
{
@@ -9,7 +9,7 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D (#23 M6-T5) tests for <see cref="AuditLogPartitionMaintenanceService"/>.
/// Tests for <see cref="AuditLogPartitionMaintenanceService"/>.
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
/// the real EF/MSSQL implementation is exercised by the
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
@@ -28,6 +28,7 @@ public class AuditLogPartitionMaintenanceServiceTests
public int EnsureCallCount;
public Exception? ThrowOnce;
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
{
Interlocked.Increment(ref EnsureCallCount);
@@ -39,6 +40,7 @@ public class AuditLogPartitionMaintenanceServiceTests
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
}
/// <inheritdoc />
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
}
@@ -50,12 +52,27 @@ public class AuditLogPartitionMaintenanceServiceTests
/// </summary>
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
{
/// <summary>Gets the log entries captured so far.</summary>
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
/// <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,
@@ -77,6 +94,8 @@ public class AuditLogPartitionMaintenanceServiceTests
return services.BuildServiceProvider();
}
/// <summary>Verifies that the service starts and stops without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartStop_NoExceptions()
{
@@ -113,6 +132,8 @@ public class AuditLogPartitionMaintenanceServiceTests
Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
}
/// <summary>Verifies that an exception thrown during maintenance is logged and not propagated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SafeMaintain_ExceptionLogged_NotPropagated()
{
@@ -152,10 +173,12 @@ public class AuditLogPartitionMaintenanceServiceTests
Assert.Equal(1, maintenance.EnsureCallCount);
}
/// <summary>Verifies that stopping the service after it has been disposed does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StopAsync_AfterDispose_DoesNotThrow()
{
// Regression (Gitea #15 follow-up): Dispose tears down the CTS that
// Regression: Dispose tears down the CTS that
// StopAsync cancels, and the host does not guarantee that every
// IHostedService.StopAsync is driven before the DI container is
// disposed — WebApplicationFactory's teardown reaches Dispose first and
@@ -18,18 +18,20 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle C (#23 M6-T4) tests for <see cref="AuditLogPurgeActor"/>. The fast,
/// Tests for <see cref="AuditLogPurgeActor"/>. The fast,
/// schedule-only tests substitute a recording stub for
/// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation
/// + event-publish machinery can be exercised without an MSSQL container.
/// The end-to-end "real partition gets switched out" assertion lives in the
/// repository tests (Bundle C of M6-T4); this actor file is purely about the
/// repository tests; this actor file is purely about the
/// actor's policy decisions.
/// </summary>
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
/// <param name="fixture">The shared MSSQL migration fixture.</param>
public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
@@ -45,38 +47,52 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <summary>Gets the thresholds passed to each partition-boundary query.</summary>
public List<DateTime> ThresholdQueries { get; } = new();
/// <summary>Gets the partition boundaries that were switched out, in call order.</summary>
public List<DateTime> SwitchedBoundaries { get; } = new();
/// <summary>Gets or sets the function that returns the row count reported for a switched boundary.</summary>
public Func<DateTime, long> RowsPerBoundary { get; set; } = _ => 0L;
/// <summary>Gets or sets the boundary whose switch-out should throw, if any.</summary>
public DateTime? ThrowOnBoundary { get; set; }
/// <summary>Gets or sets the exception thrown for <see cref="ThrowOnBoundary"/>; a default failure is used when null.</summary>
public Exception? BoundaryException { get; set; }
// Task 4: captures the maintenance command timeout the actor threads through to the
// Captures the maintenance command timeout the actor threads through to the
// switch-out + per-channel purge, so tests can assert the options value reaches the repo.
/// <summary>Gets the command timeouts passed to each partition switch-out call.</summary>
public List<TimeSpan?> SwitchTimeouts { get; } = new();
/// <summary>Gets the command timeouts passed to each per-channel purge call.</summary>
public List<TimeSpan?> ChannelTimeouts { get; } = new();
// M5.5 (T3): records every per-channel purge call as
// Records every per-channel purge call as
// (channel, threshold, batchSize) so tests can assert which channels the
// actor chose to purge and with what window.
/// <summary>Gets every per-channel purge call as a channel, threshold, batch size and row ceiling.</summary>
public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new();
/// <summary>Gets or sets the function that returns the row count reported for a purged channel.</summary>
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
// The actor enumerator returns whichever list is configured here.
// Mutating this between ticks lets tests simulate "no longer
// eligible" boundaries on the second tick.
/// <summary>Gets or sets the partition boundaries returned by the next threshold query.</summary>
public List<DateTime> Boundaries { get; set; } = new();
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
{
SwitchTimeouts.Add(commandTimeout);
@@ -88,6 +104,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
return Task.FromResult(RowsPerBoundary(monthBoundary));
}
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default)
{
@@ -95,6 +112,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
}
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
{
@@ -103,18 +121,22 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
return Task.FromResult(RowsPerChannel(channel));
}
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
@@ -194,6 +216,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 1. Tick_Fires_OnDailyInterval
// ---------------------------------------------------------------------
/// <summary>Verifies that the purge actor runs a purge pass on each daily interval tick.</summary>
[Fact]
public void Tick_Fires_OnDailyInterval()
{
@@ -216,6 +239,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 2. Tick_OldPartitions_SwitchedOut
// ---------------------------------------------------------------------
/// <summary>Verifies that a tick switches out partitions older than the retention threshold.</summary>
[Fact]
public void Tick_OldPartitions_SwitchedOut()
{
@@ -247,6 +271,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 3. Tick_NewerPartitions_Untouched
// ---------------------------------------------------------------------
/// <summary>Verifies that a tick leaves partitions newer than the retention threshold untouched.</summary>
[Fact]
public void Tick_NewerPartitions_Untouched()
{
@@ -276,6 +301,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 4. Tick_PublishesPurgedEvent_WithRowCount
// ---------------------------------------------------------------------
/// <summary>Verifies that a tick publishes a purged event carrying the number of rows removed.</summary>
[Fact]
public void Tick_PublishesPurgedEvent_WithRowCount()
{
@@ -301,6 +327,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 5. Tick_SwitchThrows_OtherPartitionsStillProcessed (continue-on-error)
// ---------------------------------------------------------------------
/// <summary>Verifies that a failed switch-out does not prevent the remaining partitions from being processed.</summary>
[Fact]
public void Tick_SwitchThrows_OtherPartitionsStillProcessed()
{
@@ -333,6 +360,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished
// ---------------------------------------------------------------------
/// <summary>Verifies that a real partition is switched out end to end, its rows are removed and a purged event is published.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
{
@@ -401,7 +430,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Options.Create(auditOptions),
NullLogger<AuditLogPurgeActor>.Instance)));
// Fish for the Jan-2026 partition boundary — the only eligible one in this
// Fish for the January 2026 partition boundary — the only eligible one in this
// fixture DB. The generous timeout covers the real drop-and-rebuild dance
// against MSSQL which routinely takes a couple of seconds on a busy dev container.
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
@@ -436,6 +465,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 7. Threshold_UsesAuditLogOptionsRetentionDays
// ---------------------------------------------------------------------
/// <summary>Verifies that the purge threshold is derived from the configured audit log retention days.</summary>
[Fact]
public void Threshold_UsesAuditLogOptionsRetentionDays()
{
@@ -467,9 +497,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
}
// ---------------------------------------------------------------------
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge (M5.5 T3)
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge
// ---------------------------------------------------------------------
/// <summary>Verifies that a per-channel retention override shorter than the global window triggers a channel purge.</summary>
[Fact]
public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge()
{
@@ -515,9 +546,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
}
// ---------------------------------------------------------------------
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge (M5.5 T3)
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge
// ---------------------------------------------------------------------
/// <summary>Verifies that a per-channel retention override equal to or longer than the global window does not trigger a channel purge.</summary>
[Fact]
public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge()
{
@@ -561,9 +593,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
}
// ---------------------------------------------------------------------
// 10. PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository (Task 4)
// 10. PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository
// ---------------------------------------------------------------------
/// <summary>Verifies that a purge tick passes the configured maintenance command timeout to the repository.</summary>
[Fact]
public void PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository()
{
@@ -608,9 +641,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
}
// ---------------------------------------------------------------------
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter (Task 5)
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter
// ---------------------------------------------------------------------
/// <summary>Verifies that a failing switch-out publishes a purge-failed event and increments the health failure counter.</summary>
[Fact]
public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter()
{
@@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Task 4 tests for <see cref="AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes"/> /
/// Tests for <see cref="AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes"/> /
/// <see cref="AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout"/>: the maintenance timeout
/// defaults to 30 minutes and is clamped to a 1-minute floor so a misconfigured 0/negative value
/// cannot yield a zero/negative ADO.NET command timeout (arch-review 04, S2).
/// </summary>
public class AuditLogPurgeOptionsTests
{
/// <summary>Verifies that the maintenance command timeout defaults to 30 minutes.</summary>
[Fact]
public void MaintenanceCommandTimeout_Defaults_To_30_Minutes()
{
@@ -18,6 +19,9 @@ public class AuditLogPurgeOptionsTests
Assert.Equal(TimeSpan.FromMinutes(30), options.ResolvedMaintenanceCommandTimeout);
}
/// <summary>Verifies that the resolved maintenance command timeout is clamped to a minimum of one minute.</summary>
/// <param name="configured">The configured timeout in minutes.</param>
/// <param name="expectedMinutes">The expected resolved timeout in minutes.</param>
[Theory]
[InlineData(0, 1)] // clamp floor — a zero config value cannot become a zero timeout
[InlineData(-5, 1)] // clamp floor — negatives are rejected by ADO.NET
@@ -12,6 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// </summary>
public class AuditLogPurgeOptionsValidatorTests
{
/// <summary>Verifies that the default options are valid.</summary>
/// <returns>The result.</returns>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -19,6 +21,8 @@ public class AuditLogPurgeOptionsValidatorTests
Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded);
}
/// <summary>Verifies that a zero interval in hours fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void ZeroIntervalHours_Fails()
{
@@ -29,6 +33,8 @@ public class AuditLogPurgeOptionsValidatorTests
f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal));
}
/// <summary>Verifies that a zero channel purge batch size fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void ZeroChannelPurgeBatchSize_Fails()
{
@@ -40,6 +46,8 @@ public class AuditLogPurgeOptionsValidatorTests
f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal));
}
/// <summary>Verifies that a zero maintenance command timeout fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void ZeroMaintenanceCommandTimeout_Fails()
{
@@ -11,16 +11,17 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor
/// failure bridge. M5 wired the SITE bridge
/// Coverage for the central-side payload-filter redactor
/// failure bridge. The SITE bridge
/// (<c>HealthMetricsAuditRedactionFailureCounter</c>) that pushes increments
/// into the site health report; M6 mirrors that with
/// into the site health report; the central bridge mirrors that with
/// <see cref="CentralAuditRedactionFailureCounter"/> so the same payload
/// filter — when it runs on the central writer paths — surfaces failures on
/// the central <see cref="AuditCentralHealthSnapshot"/>.
/// </summary>
public class CentralAuditRedactionFailureCounterTests : TestKit
{
/// <summary>Verifies that incrementing the counter routes the failure to the central health snapshot.</summary>
[Fact]
public void Increment_Routes_To_Snapshot()
{
@@ -34,6 +35,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
Assert.Equal(3, snapshot.AuditRedactionFailure);
}
/// <summary>Verifies that constructing the counter with a null snapshot throws.</summary>
[Fact]
public void Construction_With_Null_Snapshot_Throws()
{
@@ -41,6 +43,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
() => new CentralAuditRedactionFailureCounter(null!));
}
/// <summary>Verifies that registering central maintenance replaces the redaction failure counter with the central implementation.</summary>
[Fact]
public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl()
{
@@ -72,11 +75,12 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
Assert.IsType<CentralAuditRedactionFailureCounter>(counter);
}
/// <summary>Verifies that the default redaction failure counter registered by the audit log is a no-op.</summary>
[Fact]
public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp()
{
// Sanity check: without AddAuditLogCentralMaintenance the default
// remains the NoOp from M5 — the central bridge only takes effect
// remains the NoOp — the central bridge only takes effect
// when the central-only registration runs.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -13,7 +13,7 @@ using IAuditInboundCeilingHitsCounter = ZB.MOM.WW.ScadaBridge.AuditLog.Central.I
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle E (M6-T8) regression coverage for the central-side audit-write
/// Regression coverage for the central-side audit-write
/// failure counter. <see cref="CentralAuditWriter"/> and
/// <see cref="AuditLogIngestActor"/> both swallow repository throws (audit
/// must NEVER abort the user-facing action, alog.md §13) but bump the
@@ -37,33 +37,43 @@ public class CentralAuditWriteFailuresTests : TestKit
/// </summary>
private sealed class ThrowingRepo : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated repo failure");
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
@@ -75,10 +85,14 @@ public class CentralAuditWriteFailuresTests : TestKit
private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter
{
private int _count;
/// <summary>Gets the number of increments recorded so far.</summary>
public int Count => Volatile.Read(ref _count);
/// <inheritdoc />
public void Increment() => Interlocked.Increment(ref _count);
}
/// <summary>Verifies that a forced repository failure increments the audit-write failure counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Forced_Failure_Increments_Counter()
{
@@ -101,6 +115,8 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(1, counter.Count);
}
/// <summary>Verifies that a repository failure inside the ingest actor increments the audit-write failure counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditLogIngestActor_Failure_Increments_Counter()
{
@@ -128,6 +144,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(batch.Length, counter.Count);
}
/// <summary>Verifies that the health snapshot aggregates the failure counters together with the stalled-telemetry state.</summary>
[Fact]
public void Snapshot_Aggregates_Counters_And_StalledState()
{
@@ -165,6 +182,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(1, snapshot.AuditRedactionFailure);
}
/// <summary>Verifies that a newly constructed health snapshot reports empty counters.</summary>
[Fact]
public void Snapshot_Empty_OnConstruction()
{
@@ -178,11 +196,12 @@ public class CentralAuditWriteFailuresTests : TestKit
}
// ---------------------------------------------------------------------
// M5.3 (T7) AuditInboundCeilingHits counter
// AuditInboundCeilingHits counter
// AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter.
// Incrementing through the interface surface is reflected on the snapshot.
// ---------------------------------------------------------------------
/// <summary>Verifies that the inbound ceiling-hit counter starts at zero.</summary>
[Fact]
public void AuditInboundCeilingHits_StartsAtZero()
{
@@ -190,6 +209,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(0, snapshot.AuditInboundCeilingHits);
}
/// <summary>Verifies that incrementing the ceiling-hit counter through its interface is reflected on the snapshot.</summary>
[Fact]
public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot()
{
@@ -203,6 +223,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(3, snapshot.AuditInboundCeilingHits);
}
/// <summary>Verifies that concurrent increments of the ceiling-hit counter are not lost.</summary>
[Fact]
public void AuditInboundCeilingHits_IsThreadSafe()
{
@@ -217,6 +238,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits);
}
/// <summary>Verifies that the ceiling-hit counter does not affect the other health counters.</summary>
[Fact]
public void AuditInboundCeilingHits_IsIndependentOfOtherCounters()
{
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
/// Unit tests for <see cref="CentralAuditWriter"/>, the
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
/// The writer is a thin wrapper around
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// </summary>
public class CentralAuditWriterTests
{
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
// Canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
private static AuditEvent NewEvent(Guid? eventId = null) =>
ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.Notification,
@@ -43,6 +43,8 @@ public class CentralAuditWriterTests
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
}
/// <summary>Verifies that WriteAsync passes the event to the repository's InsertIfNotExistsAsync.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
{
@@ -56,6 +58,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that WriteAsync stamps the ingested-at UTC time on the event before inserting it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
{
@@ -66,7 +70,7 @@ public class CentralAuditWriterTests
var after = DateTime.UtcNow;
await repo.Received(1).InsertIfNotExistsAsync(
// C3 (Task 2.5): IngestedAtUtc now rides in DetailsJson on the canonical
// IngestedAtUtc now rides in DetailsJson on the canonical
// record — read it back via the decomposed row view.
Arg.Is<AuditEvent>(e =>
e.AsRow().IngestedAtUtc != null &&
@@ -75,6 +79,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an exception thrown by the repository during WriteAsync does not propagate to the caller.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
{
@@ -90,6 +96,8 @@ public class CentralAuditWriterTests
await writer.WriteAsync(NewEvent());
}
/// <summary>Verifies that WriteAsync resolves the repository from a fresh scope on every call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
{
@@ -114,6 +122,7 @@ public class CentralAuditWriterTests
Assert.NotSame(instances[0], instances[1]);
}
/// <summary>Verifies that constructing the writer with a null service provider throws.</summary>
[Fact]
public void Constructor_NullServices_Throws()
{
@@ -121,6 +130,7 @@ public class CentralAuditWriterTests
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
}
/// <summary>Verifies that constructing the writer with a null logger throws.</summary>
[Fact]
public void Constructor_NullLogger_Throws()
{
@@ -129,7 +139,7 @@ public class CentralAuditWriterTests
() => new CentralAuditWriter(services, null!));
}
// ----- SourceNode stamping (Task 12) ----- //
// ----- SourceNode stamping ----- //
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
INodeIdentityProvider? nodeIdentity)
@@ -147,6 +157,8 @@ public class CentralAuditWriterTests
return (writer, repo);
}
/// <summary>Verifies that WriteAsync stamps the source node from the identity provider when the event has none.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
{
@@ -159,6 +171,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that WriteAsync preserves a source node supplied by the caller.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
{
@@ -172,6 +186,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that WriteAsync leaves the source node null when the identity provider returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
{
@@ -184,11 +200,13 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that WriteAsync passes the caller's source node through unchanged when no identity provider is injected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
{
// Locks the back-compat contract for the optional `nodeIdentity = null`
// ctor parameter: when no provider is wired (e.g. legacy M4 test
// ctor parameter: when no provider is wired (e.g. legacy test
// composition roots), the writer must not stamp — caller value passes
// through unmodified. Distinct code path from
// "provider supplied, returns null", which the test above covers.
@@ -201,6 +219,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that WriteAsync leaves the source node null when neither the caller nor an identity provider supplies one.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected()
{
@@ -12,7 +12,7 @@ using ProtoPullResponse = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEven
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle (M6) tests for <see cref="GrpcPullAuditEventsClient"/> — the
/// Tests for <see cref="GrpcPullAuditEventsClient"/> — the
/// production <see cref="IPullAuditEventsClient"/> that dials a site over gRPC
/// and issues the <c>PullAuditEvents</c> unary RPC for the reconciliation loop.
/// The real <c>GrpcChannel</c> is replaced by an injected
@@ -29,7 +29,10 @@ public class GrpcPullAuditEventsClientTests
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
/// <summary>Initializes the enumerator with the fixed set of sites it will return.</summary>
/// <param name="sites">The sites the enumerator returns on every call.</param>
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
@@ -41,8 +44,11 @@ public class GrpcPullAuditEventsClientTests
/// </summary>
private sealed class FakeInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
{
/// <summary>Gets the endpoint most recently dialed through this invoker.</summary>
public string? Endpoint { get; private set; }
/// <summary>Gets the request most recently passed to this invoker.</summary>
public ProtoPullRequest? Request { get; private set; }
/// <summary>Gets the number of times this invoker has been called.</summary>
public int CallCount { get; private set; }
private readonly ProtoPullResponse? _response;
@@ -54,9 +60,17 @@ public class GrpcPullAuditEventsClientTests
_throw = toThrow;
}
/// <summary>Creates an invoker that returns the given response.</summary>
/// <param name="response">The response returned from every invocation.</param>
/// <returns>An invoker that returns the given response.</returns>
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
/// <summary>Creates an invoker that throws the given exception.</summary>
/// <param name="ex">The exception thrown from every invocation.</param>
/// <returns>An invoker that throws the given exception.</returns>
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
// Records the call and returns the canned response or throws the canned exception.
/// <inheritdoc />
public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{
@@ -72,17 +86,22 @@ public class GrpcPullAuditEventsClientTests
}
/// <summary>
/// Per-endpoint fake invoker (Task 18): routes dials by endpoint string so a
/// Per-endpoint fake invoker: routes dials by endpoint string so a
/// NodeA-down / NodeB-up failover can be scripted. Records dialed endpoints.
/// </summary>
private sealed class PerEndpointInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
{
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
/// <summary>Gets the endpoints dialed so far, in call order.</summary>
public List<string> Dialed { get; } = new();
/// <summary>Initializes the invoker with the scripted response factory for each endpoint.</summary>
/// <param name="byEndpoint">The response factory keyed by endpoint address.</param>
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
_byEndpoint = byEndpoint;
// Records the dialed endpoint and returns the response scripted for it.
/// <inheritdoc />
public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{
@@ -100,6 +119,8 @@ public class GrpcPullAuditEventsClientTests
status: AuditStatus.Delivered,
sourceSiteId: "site-a"));
/// <summary>Verifies that the pull dials the resolved endpoint and maps the returned events oldest first.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_dials_the_resolved_endpoint_and_maps_events_oldest_first()
{
@@ -132,6 +153,8 @@ public class GrpcPullAuditEventsClientTests
Assert.Equal(newer, result.Events[1].EventId);
}
/// <summary>Verifies that pulling from a site with no known endpoint returns an empty response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
{
@@ -148,6 +171,9 @@ public class GrpcPullAuditEventsClientTests
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
}
/// <summary>Verifies that tolerable transport faults are swallowed and produce an empty response.</summary>
/// <param name="code">The gRPC status code the invoker fails with.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(StatusCode.Unavailable)] // connection refused / site offline
[InlineData(StatusCode.DeadlineExceeded)] // slow site / network blip
@@ -167,6 +193,8 @@ public class GrpcPullAuditEventsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that connection-layer faults are swallowed and produce an empty response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
{
@@ -184,6 +212,8 @@ public class GrpcPullAuditEventsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that unexpected faults are swallowed and produce an empty response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
{
@@ -203,6 +233,8 @@ public class GrpcPullAuditEventsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that an unspecified minimum-value cursor does not throw and the site is still dialed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
{
@@ -233,10 +265,12 @@ public class GrpcPullAuditEventsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that the pull fails over to the secondary node when the primary raises a transport fault.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
{
// Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the
// NodeA (primary) is unreachable, NodeB (fallback) answers — the
// pull must return NodeB's events rather than collapsing to empty.
var id = Guid.NewGuid();
var fallbackResponse = new ProtoPullResponse { MoreAvailable = false };
@@ -259,6 +293,8 @@ public class GrpcPullAuditEventsClientTests
Assert.Equal(id, evt.EventId);
}
/// <summary>Verifies that no failover is attempted when the site has no fallback endpoint configured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
{
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Tests for <see cref="GrpcPullSiteCallsClient"/> — the production
/// <see cref="IPullSiteCallsClient"/> that dials a site over gRPC and issues the
/// <c>PullSiteCalls</c> unary RPC for the Site Call Audit (#22) reconciliation
/// <c>PullSiteCalls</c> unary RPC for the Site Call Audit reconciliation
/// loop. The real <c>GrpcChannel</c> is replaced by an injected
/// <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/> seam so the
/// client's mapping / ordering / SourceSite-restamp / fault-swallowing behaviour
@@ -26,15 +26,21 @@ public class GrpcPullSiteCallsClientTests
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
/// <summary>Initializes a new instance of the <c>StaticEnumerator</c> class.</summary>
/// <param name="sites">The sites.</param>
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
private sealed class FakeInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
{
/// <summary>Gets the endpoint most recently dialed.</summary>
public string? Endpoint { get; private set; }
/// <summary>Gets the request most recently received.</summary>
public ProtoPullRequest? Request { get; private set; }
/// <summary>Gets the number of calls made.</summary>
public int CallCount { get; private set; }
private readonly ProtoPullResponse? _response;
@@ -46,9 +52,16 @@ public class GrpcPullSiteCallsClientTests
_throw = toThrow;
}
/// <summary>Creates an invoker that returns the given response.</summary>
/// <param name="response">The response to return.</param>
/// <returns>A fake invoker returning <paramref name="response"/>.</returns>
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
/// <summary>Creates an invoker that throws the given exception.</summary>
/// <param name="ex">The exception to throw.</param>
/// <returns>A fake invoker that throws <paramref name="ex"/>.</returns>
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
/// <inheritdoc />
public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{
@@ -64,18 +77,22 @@ public class GrpcPullSiteCallsClientTests
}
/// <summary>
/// Per-endpoint fake invoker (Task 18): dials are routed by endpoint string so
/// Per-endpoint fake invoker: dials are routed by endpoint string so
/// a NodeA-down / NodeB-up failover can be scripted. Records every dialed
/// endpoint in order.
/// </summary>
private sealed class PerEndpointInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
{
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
/// <summary>Gets the endpoints dialed, in order.</summary>
public List<string> Dialed { get; } = new();
/// <summary>Initializes a new instance of the <c>PerEndpointInvoker</c> class.</summary>
/// <param name="byEndpoint">The by endpoint.</param>
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
_byEndpoint = byEndpoint;
/// <inheritdoc />
public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{
@@ -103,6 +120,8 @@ public class GrpcPullSiteCallsClientTests
UpdatedAtUtc = Timestamp.FromDateTime(updatedAtUtc),
};
/// <summary>Verifies that PullAsync dials the resolved endpoint, maps rows oldest first and restamps the source site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_dials_resolved_endpoint_maps_oldest_first_and_restamps_source_site()
{
@@ -144,6 +163,8 @@ public class GrpcPullSiteCallsClientTests
Assert.Equal(1, result.SiteCalls[0].RetryCount);
}
/// <summary>Verifies that PullAsync returns an empty response when the site endpoint is unknown.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
{
@@ -160,6 +181,9 @@ public class GrpcPullSiteCallsClientTests
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
}
/// <summary>Verifies that PullAsync turns tolerable transport faults into an empty response.</summary>
/// <param name="code">The code.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(StatusCode.Unavailable)]
[InlineData(StatusCode.DeadlineExceeded)]
@@ -178,6 +202,8 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that PullAsync turns connection-layer faults into an empty response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
{
@@ -193,6 +219,8 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that PullAsync turns unexpected faults into an empty response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
{
@@ -208,6 +236,8 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that PullAsync skips a poison row and still returns the good rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_skips_poison_row_and_returns_the_good_rows()
{
@@ -244,6 +274,8 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that PullAsync with a MinValue unspecified-kind cursor does not throw and still dials the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
{
@@ -271,10 +303,12 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>Verifies that PullAsync fails over to node B when the primary raises a transport fault.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
{
// Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the
// NodeA (primary) is unreachable, NodeB (fallback) answers — the
// pull must return NodeB's rows rather than collapsing to empty.
var id = Guid.NewGuid();
var fallbackResponse = new ProtoPullResponse();
@@ -299,6 +333,8 @@ public class GrpcPullSiteCallsClientTests
Assert.Equal("site-a", row.SourceSite);
}
/// <summary>Verifies that PullAsync does not fail over when no fallback is configured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
{
@@ -18,19 +18,21 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle B (M6-T3) tests for <see cref="SiteAuditReconciliationActor"/>. Most
/// Tests for <see cref="SiteAuditReconciliationActor"/>. Most
/// tests substitute the <see cref="IAuditLogRepository"/> with an in-memory
/// recording stub so the actor's tick / cursor / stalled state machinery can
/// be exercised in milliseconds without an MSSQL container. The duplicate /
/// idempotency assertion uses the real <see cref="AuditLogRepository"/> against
/// the <see cref="MsSqlMigrationFixture"/> so we verify InsertIfNotExistsAsync
/// actually swallows duplicate-key collisions (the M2 Bundle A race-fix the
/// actually swallows duplicate-key collisions (the race fix the
/// reconciliation puller depends on).
/// </summary>
public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
/// <param name="fixture">The shared MSSQL migration fixture.</param>
public SiteAuditReconciliationActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
@@ -68,13 +70,17 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <summary>Gets the audit events recorded by this stub.</summary>
public List<AuditEvent> Inserted { get; } = new();
private readonly HashSet<Guid> _seen = new();
/// <summary>Gets the number of times <c>InsertIfNotExistsAsync</c> was called.</summary>
public int InsertCallCount { get; private set; }
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
InsertCallCount++;
@@ -85,33 +91,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
@@ -120,24 +134,30 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// Recording stub whose <see cref="InsertIfNotExistsAsync"/> throws for ONE
/// poison <see cref="AuditEvent.EventId"/> (every attempt) but records every
/// other row — including the synthetic ReconciliationAbandoned row the actor
/// emits once it gives up on the poison row (Task 17).
/// emits once it gives up on the poison row.
/// </summary>
private sealed class PoisonInsertRepo : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly Guid _poisonId;
private readonly Exception _fault;
/// <summary>Gets the audit events recorded by this stub.</summary>
public List<AuditEvent> Inserted { get; } = new();
private readonly HashSet<Guid> _seen = new();
/// <summary>Initializes the repository so that inserting the given event id always throws the given fault.</summary>
/// <param name="poisonId">The event id whose insert always fails.</param>
/// <param name="fault">The exception thrown on every attempt to insert the poison row.</param>
public PoisonInsertRepo(Guid poisonId, Exception fault)
{
_poisonId = poisonId;
_fault = fault;
}
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt.EventId == _poisonId)
@@ -151,33 +171,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
@@ -191,19 +219,22 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// row that also never inserts but changes EventId every pull — i.e. it never
/// individually reaches <c>MaxPermanentInsertAttempts</c>, so it keeps
/// <c>hasUnresolvedFailure</c> true and holds the cursor at <c>since</c> forever
/// (P5-6's defect trigger). Dedupes inserted rows on <see cref="AuditEvent.EventId"/>
/// (the defect trigger). Dedupes inserted rows on <see cref="AuditEvent.EventId"/>
/// exactly like the real <c>InsertIfNotExistsAsync</c>, so a test asserting on
/// <see cref="Inserted"/>'s count is exercising the same idempotency contract
/// production relies on.
/// </summary>
private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <summary>Gets the audit events recorded by this stub.</summary>
public List<AuditEvent> Inserted { get; } = new();
private readonly HashSet<Guid> _seen = new();
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (!evt.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal))
@@ -217,33 +248,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
@@ -254,7 +293,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
/// <summary>Initializes the enumerator with a fixed set of sites.</summary>
/// <param name="sites">The sites returned on every enumeration.</param>
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
@@ -266,22 +308,32 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// </summary>
private sealed class ScriptedPullClient : IPullAuditEventsClient
{
/// <summary>Gets the recorded pull calls in the order they were made.</summary>
public List<(string SiteId, DateTime SinceUtc, string? AfterId, int BatchSize)> Calls { get; } = new();
private readonly Dictionary<string, Queue<PullAuditEventsResponse>> _scripted = new();
private readonly Dictionary<string, Exception> _throwOnSite = new();
/// <summary>Queues scripted responses for the given site.</summary>
/// <param name="siteId">The site whose pulls receive the scripted responses.</param>
/// <param name="responses">The responses returned in order on successive pulls.</param>
/// <returns>This client, for chaining.</returns>
public ScriptedPullClient Script(string siteId, params PullAuditEventsResponse[] responses)
{
_scripted[siteId] = new Queue<PullAuditEventsResponse>(responses);
return this;
}
/// <summary>Makes every pull for the given site throw the given exception.</summary>
/// <param name="siteId">The site whose pulls fail.</param>
/// <param name="ex">The exception to throw.</param>
/// <returns>This client, for chaining.</returns>
public ScriptedPullClient ThrowFor(string siteId, Exception ex)
{
_throwOnSite[siteId] = ex;
return this;
}
/// <inheritdoc />
public Task<PullAuditEventsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{
@@ -373,6 +425,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 1. Timer_Fires_OnConfiguredInterval
// ---------------------------------------------------------------------
/// <summary>Verifies that the reconciliation timer fires on the configured interval.</summary>
[Fact]
public void Timer_Fires_OnConfiguredInterval()
{
@@ -400,6 +453,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 2. Tick_PullsFromEachKnownSite
// ---------------------------------------------------------------------
/// <summary>Verifies that each tick pulls from every known site.</summary>
[Fact]
public void Tick_PullsFromEachKnownSite()
{
@@ -426,6 +480,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync
// ---------------------------------------------------------------------
/// <summary>Verifies that pulled events are ingested through the repository insert-if-not-exists call.</summary>
[Fact]
public void Tick_IngestEvents_ViaInsertIfNotExistsAsync()
{
@@ -456,6 +511,8 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
/// <summary>Verifies that duplicate events are not inserted twice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Tick_Duplicates_NotDoubleInserted()
{
@@ -509,6 +566,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 5. Cursor_Advances_ToMaxOccurredAtUtc
// ---------------------------------------------------------------------
/// <summary>Verifies that the cursor advances to the maximum occurred-at timestamp of the pulled events.</summary>
[Fact]
public void Cursor_Advances_ToMaxOccurredAtUtc()
{
@@ -545,13 +603,14 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
Assert.Equal(t3, client.Calls[1].SinceUtc);
// The composite half travels too (arch-review phase-2 residual #5): the first pull has
// The composite half travels too: the first pull has
// no cursor at all, the second carries the id of the row at t3 so the site can retire
// it exactly instead of leaving the boundary instant permanently servable.
Assert.Null(client.Calls[0].AfterId);
Assert.Equal(e3.EventId.ToString(), client.Calls[1].AfterId);
}
/// <summary>Verifies that the cursor advances on the event id tiebreak when every row shares one instant.</summary>
[Fact]
public void Cursor_AdvancesOnTheIdTiebreak_WhenEveryRowSharesOneInstant()
{
@@ -583,6 +642,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
Assert.Equal(high.EventId.ToString(), client.Calls[1].AfterId);
}
/// <summary>Verifies that both cursor halves are held back while a row is still being retried.</summary>
[Fact]
public void BothCursorHalves_AreHeldBack_WhileARowIsStillBeingRetried()
{
@@ -612,41 +672,51 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// <summary>Repository whose every insert throws, so the retry hold-back path is taken.</summary>
private sealed class AlwaysThrowingRepo : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
throw new InvalidOperationException("central insert failed");
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null,
CancellationToken ct = default, int maxRows = 50_000)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
=> throw new NotSupportedException();
}
@@ -655,6 +725,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 6. Tick_OneSiteThrows_OtherSitesStillProcessed
// ---------------------------------------------------------------------
/// <summary>Verifies that a failure pulling from one site does not stop the other sites from being processed.</summary>
[Fact]
public void Tick_OneSiteThrows_OtherSitesStillProcessed()
{
@@ -686,6 +757,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 7. StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue
// ---------------------------------------------------------------------
/// <summary>Verifies that two consecutive non-draining cycles publish a stalled state of true.</summary>
[Fact]
public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue()
{
@@ -717,6 +789,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 8. StalledDetection_DrainingCycle_PublishesStalledFalse
// ---------------------------------------------------------------------
/// <summary>Verifies that a draining cycle publishes a stalled state of false.</summary>
[Fact]
public void StalledDetection_DrainingCycle_PublishesStalledFalse()
{
@@ -750,11 +823,12 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
}
// ---------------------------------------------------------------------
// 9. Task 17: a permanently-abandoned row leaves a durable
// 9. A permanently-abandoned row leaves a durable
// ReconciliationAbandoned audit record (not just a Critical log line),
// and the cursor advances past it.
// ---------------------------------------------------------------------
/// <summary>Verifies that a permanently abandoned row emits a ReconciliationAbandoned row and advances the cursor.</summary>
[Fact]
public void PermanentAbandonment_EmitsReconciliationAbandonedRow_AndAdvancesCursor()
{
@@ -804,11 +878,11 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
}
// ---------------------------------------------------------------------
// 10. P5-6: deterministic synthetic ReconciliationAbandoned EventId
// 10. Deterministic synthetic ReconciliationAbandoned EventId
// ---------------------------------------------------------------------
/// <summary>
/// Reproduces the P5-6 defect end to end: a permanently-poisoned row sits
/// Reproduces the rotating-poison defect end to end: a permanently-poisoned row sits
/// in a batch alongside a co-batch row that ALSO fails on every pull but
/// carries a fresh <see cref="AuditEvent.EventId"/> each time (modelling a
/// site that keeps producing new events with transient insert failures).
@@ -12,6 +12,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// </summary>
public class SiteAuditReconciliationOptionsValidatorTests
{
/// <summary>Verifies that the batch size is valid only up to the RPC ceiling.</summary>
/// <param name="size">The batch size under test.</param>
/// <param name="valid">Whether the batch size is expected to validate.</param>
[Theory]
[InlineData(1, true)]
[InlineData(2000, true)]
@@ -21,6 +24,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
=> Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null,
new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded);
/// <summary>Verifies that the default options pass validation.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -28,6 +32,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded);
}
/// <summary>Verifies that a zero reconciliation interval fails validation.</summary>
[Fact]
public void ZeroReconciliationInterval_Fails()
{
@@ -39,6 +44,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal));
}
/// <summary>Verifies that a zero batch size fails validation.</summary>
[Fact]
public void ZeroBatchSize_Fails()
{
@@ -50,6 +56,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal));
}
/// <summary>Verifies that a zero stalled-cycle threshold fails validation.</summary>
[Fact]
public void ZeroStalledCycles_Fails()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle E (M6-T7) tests for <see cref="SiteAuditTelemetryStalledTracker"/>.
/// Tests for <see cref="SiteAuditTelemetryStalledTracker"/>.
/// The tracker subscribes to the actor system's EventStream for
/// <see cref="SiteAuditTelemetryStalledChanged"/> publications and maintains a
/// per-site latch the central health surface can read. Since reconciliation is
@@ -36,6 +36,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
interval: TimeSpan.FromMilliseconds(20));
}
/// <summary>Verifies that a newly created tracker reports an empty snapshot.</summary>
[Fact]
public void Initial_Snapshot_IsEmpty()
{
@@ -46,6 +47,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
Assert.Empty(snapshot);
}
/// <summary>Verifies that a stalled event makes the tracker report the site as stalled.</summary>
[Fact]
public void StalledTrue_Event_TrackerReports_Stalled()
{
@@ -57,6 +59,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
Assert.True(snapshot["siteA"]);
}
/// <summary>Verifies that a not-stalled event makes the tracker report the site as not stalled.</summary>
[Fact]
public void StalledFalse_Event_TrackerReports_NotStalled()
{
@@ -72,6 +75,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
Assert.False(snapshot["siteA"]);
}
/// <summary>Verifies that multiple sites are tracked independently of one another.</summary>
[Fact]
public void Multiple_Sites_Tracked_Independently()
{
@@ -88,6 +92,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
Assert.True(snapshot["siteC"]);
}
/// <summary>Verifies that constructing the tracker with a null actor system throws.</summary>
[Fact]
public void Constructor_With_Null_ActorSystem_Throws()
{
@@ -95,6 +100,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
() => new SiteAuditTelemetryStalledTracker((ActorSystem)null!));
}
/// <summary>Verifies that disposing the tracker unsubscribes it from the event stream.</summary>
[Fact]
public void Dispose_Unsubscribes_From_EventStream()
{
@@ -41,6 +41,8 @@ public class SiteEnumeratorTests
return services.BuildServiceProvider();
}
/// <summary>Verifies that sites with a NodeA address are projected and sites with a blank address are skipped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnumerateAsync_ProjectsSitesWithNodeAAddress_AndSkipsBlankOnes()
{
@@ -60,6 +62,8 @@ public class SiteEnumeratorTests
Assert.Equal("http://site-a:8083", entry.GrpcEndpoint);
}
/// <summary>Verifies that a site with a null NodeA address is skipped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnumerateAsync_SkipsNullNodeAAddress()
{
@@ -76,10 +80,12 @@ public class SiteEnumeratorTests
Assert.Empty(result);
}
/// <summary>Verifies that a site with distinct NodeA and NodeB addresses projects NodeB as the fallback endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnumerateAsync_BothAddresses_ProjectsNodeBAsFallback()
{
// Task 18: a site with both NodeA and a DISTINCT NodeB yields
// A site with both NodeA and a DISTINCT NodeB yields
// SiteEntry(primary=NodeA, FallbackGrpcEndpoint=NodeB).
var repository = Substitute.For<ISiteRepository>();
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
@@ -95,10 +101,12 @@ public class SiteEnumeratorTests
Assert.Equal("http://site-b:8083", entry.FallbackGrpcEndpoint);
}
/// <summary>Verifies that a site with only a NodeB address is not skipped and has no fallback endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnumerateAsync_OnlyNodeB_IsNotSkipped_AndHasNoFallback()
{
// Task 18: a site with a blank NodeA but a valid NodeB is no longer
// A site with a blank NodeA but a valid NodeB is no longer
// skipped — NodeB becomes the primary and there is no separate fallback.
var repository = Substitute.For<ISiteRepository>();
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
@@ -114,6 +122,8 @@ public class SiteEnumeratorTests
Assert.Null(entry.FallbackGrpcEndpoint);
}
/// <summary>Verifies that enumeration returns an empty list when there are no sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnumerateAsync_ReturnsEmpty_WhenNoSites()
{