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
@@ -179,7 +179,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
/// cleanly on another. Free-text columns are run through /// cleanly on another. Free-text columns are run through
/// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a /// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a
/// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as /// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as
/// a formula or DDE trigger by a spreadsheet application (CWE-1236). GUID/enum/numeric/date/bool columns this /// a formula or DDE trigger by a spreadsheet application (CSV formula injection). GUID/enum/numeric/date/bool columns this
/// formatter generates itself are never neutralized. /// formatter generates itself are never neutralized.
/// </summary> /// </summary>
/// <param name="evt">The audit event view to format as a CSV row.</param> /// <param name="evt">The audit event view to format as a CSV row.</param>
@@ -228,7 +228,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
return; return;
} }
// OWASP CSV-injection neutralization (CWE-1236): prefix a literal single // OWASP CSV-injection neutralization: prefix a literal single
// quote before RFC 4180 quoting so a free-text cell starting with a // quote before RFC 4180 quoting so a free-text cell starting with a
// formula/DDE trigger character renders as inert text in Excel. // formula/DDE trigger character renders as inert text in Excel.
if (freeText && NeedsFormulaNeutralization(value)) if (freeText && NeedsFormulaNeutralization(value))
@@ -708,7 +708,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
} }
/// <summary> /// <summary>
/// Arms the backoff retry for a failed fan-out: <c>reconnectDelay × 2^(failures-1)</c>, /// Arms the backoff retry for a failed fan-out: <c>reconnectDelay × 2^(failures − 1)</c>,
/// capped at <see cref="MaxSeedRetryBackoffMultiplier"/> × the reconcile interval. Once /// capped at <see cref="MaxSeedRetryBackoffMultiplier"/> × the reconcile interval. Once
/// the cap is reached the periodic reconcile tick is doing the same work anyway, so no /// the cap is reached the periodic reconcile tick is doing the same work anyway, so no
/// extra timer is armed. /// extra timer is armed.
@@ -26,7 +26,7 @@ public sealed record SiteHealthState
/// <c>null</c> if the site is known only via heartbeats and has not yet sent /// <c>null</c> if the site is known only via heartbeats and has not yet sent
/// a report. Used by the UI to surface report staleness during failover; /// a report. Used by the UI to surface report staleness during failover;
/// the <c>null</c> case must be rendered as "no report yet" rather than as a /// the <c>null</c> case must be rendered as "no report yet" rather than as a
/// timestamp (a <c>default</c> sentinel would display as year-0001). /// timestamp (a <c>default</c> sentinel would display as year 0001).
/// </summary> /// </summary>
public DateTimeOffset? LastReportReceivedAt { get; init; } public DateTimeOffset? LastReportReceivedAt { get; init; }
@@ -2560,7 +2560,7 @@ public class ManagementActor : ReceiveActor
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode; if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials; if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
// Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an // Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an
// omitted authority/scope leaves the stored value (or its M365 default) intact. // omitted authority/scope leaves the stored value (or its Microsoft 365 default) intact.
if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority; if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope; if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
// Preserve-if-null for the transport too: an omitted value keeps the stored // Preserve-if-null for the transport too: an omitted value keeps the stored
@@ -15,8 +15,8 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests;
/// <summary> /// <summary>
/// Bundle E (M2 Task E1) DI surface tests for <c>AddAuditLog</c>. M1 shipped /// DI surface tests for <c>AddAuditLog</c>. The initial version was an
/// the options-only scaffold; M2 extends it with the site writer chain /// options-only scaffold; it was then extended with the site writer chain
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> + /// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators /// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>, /// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
@@ -41,6 +41,8 @@ public class AddAuditLogTests
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
/// <summary>Verifies that AddAuditLog registers the audit log options.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_RegistersAuditLogOptions() public void AddAuditLog_RegistersAuditLogOptions()
{ {
@@ -52,6 +54,8 @@ public class AddAuditLogTests
Assert.NotNull(opts!.Value); Assert.NotNull(opts!.Value);
} }
/// <summary>Verifies that AddAuditLog throws when the service collection is null.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_NullServices_Throws() public void AddAuditLog_NullServices_Throws()
{ {
@@ -61,6 +65,8 @@ public class AddAuditLogTests
() => ServiceCollectionExtensions.AddAuditLog(null!, config)); () => ServiceCollectionExtensions.AddAuditLog(null!, config));
} }
/// <summary>Verifies that AddAuditLog throws when the configuration is null.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_NullConfig_Throws() public void AddAuditLog_NullConfig_Throws()
{ {
@@ -70,8 +76,10 @@ public class AddAuditLogTests
() => services.AddAuditLog(null!)); () => services.AddAuditLog(null!));
} }
// -- Bundle E (M2 Task E1) --------------------------------------------- // -- Site writer chain DI registrations ---------------------------------------------
/// <summary>Verifies that AddAuditLog registers the SQLite audit writer as a singleton resolvable from DI.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI() public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
{ {
@@ -90,6 +98,8 @@ public class AddAuditLogTests
Assert.Same(writer, provider.GetService<SqliteAuditWriter>()); Assert.Same(writer, provider.GetService<SqliteAuditWriter>());
} }
/// <summary>Verifies that AddAuditLog registers IAuditWriter as the fallback audit writer.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter() public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
{ {
@@ -104,6 +114,8 @@ public class AddAuditLogTests
Assert.IsType<FallbackAuditWriter>(writer); Assert.IsType<FallbackAuditWriter>(writer);
} }
/// <summary>Verifies that AddAuditLog registers ISiteAuditQueue as the same instance as the SQLite audit writer.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter() public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
{ {
@@ -123,6 +135,8 @@ public class AddAuditLogTests
Assert.Same(writer, queue); Assert.Same(writer, queue);
} }
/// <summary>Verifies that AddAuditLog registers the ring-buffer fallback as a singleton.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Registers_RingBufferFallback_Singleton() public void AddAuditLog_Registers_RingBufferFallback_Singleton()
{ {
@@ -136,6 +150,8 @@ public class AddAuditLogTests
Assert.Same(ring, provider.GetService<RingBufferFallback>()); Assert.Same(ring, provider.GetService<RingBufferFallback>());
} }
/// <summary>Verifies that AddAuditLog registers a no-op audit write failure counter by default.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault() public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
{ {
@@ -149,6 +165,8 @@ public class AddAuditLogTests
Assert.IsType<NoOpAuditWriteFailureCounter>(counter); Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
} }
/// <summary>Verifies that AddAuditLog registers a no-op site stream audit client by default.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault() public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
{ {
@@ -162,8 +180,10 @@ public class AddAuditLogTests
Assert.IsType<NoOpSiteStreamAuditClient>(client); Assert.IsType<NoOpSiteStreamAuditClient>(client);
} }
// -- M4 Bundle B (B1) central direct-write audit writer ----------------- // -- Central direct-write audit writer -----------------
/// <summary>Verifies that AddAuditLog registers ICentralAuditWriter as the central audit writer.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter() public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter()
{ {
@@ -177,6 +197,8 @@ public class AddAuditLogTests
Assert.IsType<CentralAuditWriter>(writer); Assert.IsType<CentralAuditWriter>(writer);
} }
/// <summary>Verifies that the ICentralAuditWriter registration is a singleton.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_ICentralAuditWriter_IsSingleton() public void AddAuditLog_ICentralAuditWriter_IsSingleton()
{ {
@@ -190,6 +212,8 @@ public class AddAuditLogTests
Assert.Same(w1, w2); Assert.Same(w1, w2);
} }
/// <summary>Verifies that the SQLite writer options bind from configuration and round-trip.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter() public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
{ {
@@ -208,6 +232,8 @@ public class AddAuditLogTests
Assert.Equal(75, opts.FlushIntervalMs); Assert.Equal(75, opts.FlushIntervalMs);
} }
/// <summary>Verifies that the site telemetry options bind from configuration and round-trip.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry() public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
{ {
@@ -224,8 +250,10 @@ public class AddAuditLogTests
Assert.Equal(60, opts.IdleIntervalSeconds); Assert.Equal(60, opts.IdleIntervalSeconds);
} }
// -- Bundle G (M2 Task G1) Site Health Monitoring bridge ---------------- // -- Site Health Monitoring bridge ----------------
/// <summary>Verifies that the health metrics bridge swaps the failure counter for the health metrics implementation.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLogHealthMetricsBridge_Swaps_FailureCounter_To_HealthMetrics_Implementation() public void AddAuditLogHealthMetricsBridge_Swaps_FailureCounter_To_HealthMetrics_Implementation()
{ {
@@ -251,6 +279,8 @@ public class AddAuditLogTests
Assert.IsType<HealthMetricsAuditWriteFailureCounter>(counter); Assert.IsType<HealthMetricsAuditWriteFailureCounter>(counter);
} }
/// <summary>Verifies that the health metrics bridge still resolves without health monitoring but errors when used.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLogHealthMetricsBridge_Without_HealthMonitoring_Still_Resolves_But_Errors_On_Use() public void AddAuditLogHealthMetricsBridge_Without_HealthMonitoring_Still_Resolves_But_Errors_On_Use()
{ {
@@ -276,10 +306,12 @@ public class AddAuditLogTests
() => provider.GetRequiredService<IAuditWriteFailureCounter>()); () => provider.GetRequiredService<IAuditWriteFailureCounter>());
} }
/// <summary>Verifies that registering the health metrics bridge twice does not register the hosted service twice.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void AddAuditLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService() public void AddAuditLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService()
{ {
// AuditLog-011: AddHostedService has no TryAdd variant, so a second // AddHostedService has no TryAdd variant, so a second
// call without the sentinel guard would spin up a second // call without the sentinel guard would spin up a second
// SiteAuditBacklogReporter on the same SQLite file. The helper must // SiteAuditBacklogReporter on the same SQLite file. The helper must
// be a no-op on the second call — exactly one hosted-service // be a no-op on the second call — exactly one hosted-service
@@ -19,9 +19,9 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>'s M3 combined- /// Tests for <see cref="AuditLogIngestActor"/>'s combined-telemetry
/// telemetry dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/> /// dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
/// as the M1 + M2 repository tests so the actor exercises real /// as the audit-log and site-call repository tests so the actor exercises real
/// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> + /// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> +
/// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL /// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL
/// database. The transaction commits or rolls back inside one /// database. The transaction commits or rolls back inside one
@@ -31,6 +31,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
{ {
private readonly MsSqlMigrationFixture _fixture; 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) public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -125,6 +127,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
serviceProvider, serviceProvider,
NullLogger<AuditLogIngestActor>.Instance))); 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] [SkippableFact]
public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId() public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId()
{ {
@@ -156,6 +160,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(siteCall.Status, siteCallRow!.Status); 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] [SkippableFact]
public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId() public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId()
{ {
@@ -195,6 +201,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(1, siteCallCount); 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] [SkippableFact]
public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall() public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall()
{ {
@@ -206,7 +214,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
var sp = BuildServiceProvider(); var sp = BuildServiceProvider();
var actor = CreateActor(sp); 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( var (auditSubmit, siteCallSubmit) = NewEntry(
siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted); siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted);
actor.Tell( actor.Tell(
@@ -214,7 +222,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
TestActor); TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15)); 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 // SiteCalls Status=Attempted — monotonic upsert wins). Same
// TrackedOperationId throughout. // TrackedOperationId throughout.
var (auditAttempt, siteCallAttempt) = NewEntry( var (auditAttempt, siteCallAttempt) = NewEntry(
@@ -242,6 +250,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(1, siteCallRow.RetryCount); 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] [SkippableFact]
public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow() public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow()
{ {
@@ -273,6 +283,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Null(siteCallRow); 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] [SkippableFact]
public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked() public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked()
{ {
@@ -303,6 +315,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(5, siteCallCount); 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] [SkippableFact]
public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues() public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues()
{ {
@@ -348,7 +362,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
} }
/// <summary> /// <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 /// batch fast path fails) must run under its own short
/// <see cref="AuditLogIngestActor.IngestFallbackBudget"/>, not an unbounded /// <see cref="AuditLogIngestActor.IngestFallbackBudget"/>, not an unbounded
/// wait — otherwise a single slow/wedged downstream call can freeze this /// 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 /// <c>ExpectMsg</c> below would time out and fail, which is the fail-unfixed
/// proof for this fix. /// proof for this fix.
/// </remarks> /// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task Receive_CachedTelemetry_PerEntryFallbackHangs_BudgetBoundsWait_RemainderSkipped() public async Task Receive_CachedTelemetry_PerEntryFallbackHangs_BudgetBoundsWait_RemainderSkipped()
{ {
@@ -430,38 +445,51 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
/// </summary> /// </summary>
private sealed class BatchFailingAuditRepo : IAuditLogRepository private sealed class BatchFailingAuditRepo : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly IAuditLogRepository _inner; 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; } public BatchFailingAuditRepo(IAuditLogRepository inner) { _inner = inner; }
/// <inheritdoc />
public Task<int> InsertManyIfNotExistsAsync( public Task<int> InsertManyIfNotExistsAsync(
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default) => IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated batch ingest failure — forces the per-entry fallback"); throw new InvalidOperationException("simulated batch ingest failure — forces the per-entry fallback");
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
_inner.InsertIfNotExistsAsync(evt, ct); _inner.InsertIfNotExistsAsync(evt, ct);
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct); _inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync( public Task<long> SwitchOutPartitionAsync(
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct); _inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows); _inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) => DateTime threshold, CancellationToken ct = default) =>
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct); _inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct); _inner.GetKpiSnapshotAsync(window, nowUtc, ct);
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
_inner.GetExecutionTreeAsync(executionId, ct); _inner.GetExecutionTreeAsync(executionId, ct);
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct); _inner.GetDistinctSourceNodesAsync(ct);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct); _inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
@@ -471,15 +499,17 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
/// Test double — <see cref="UpsertAsync"/> hangs on /// Test double — <see cref="UpsertAsync"/> hangs on
/// <c>Task.Delay(Timeout.InfiniteTimeSpan, ct)</c> until the caller's token /// <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 /// 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 /// prove the per-entry fallback loop's own budget bounds the wait rather blocking forever on a wedged downstream call.
/// 2026-08-19 P5-1) rather than blocking forever on a wedged downstream call.
/// </summary> /// </summary>
private sealed class HangingSiteCallRepo : ISiteCallAuditRepository private sealed class HangingSiteCallRepo : ISiteCallAuditRepository
{ {
private int _callCount; private int _callCount;
/// <summary>Gets the number of times <see cref="UpsertAsync"/> has been invoked.</summary>
public int CallCount => _callCount; public int CallCount => _callCount;
/// <summary>Gets the cancellation token most recently passed to <see cref="UpsertAsync"/>.</summary>
public CancellationToken? ReceivedToken { get; private set; } public CancellationToken? ReceivedToken { get; private set; }
/// <inheritdoc />
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{ {
Interlocked.Increment(ref _callCount); Interlocked.Increment(ref _callCount);
@@ -487,19 +517,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false); await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
} }
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync( public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync( public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
@@ -513,22 +549,31 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository
{ {
private readonly ISiteCallAuditRepository _inner; 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; } public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; }
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated SiteCalls upsert failure"); throw new InvalidOperationException("simulated SiteCalls upsert failure");
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct); _inner.GetAsync(id, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync( public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct); _inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct); _inner.PurgeTerminalAsync(olderThanUtc, ct);
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync( public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct); _inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
@@ -543,11 +588,15 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
{ {
private readonly ISiteCallAuditRepository _inner; private readonly ISiteCallAuditRepository _inner;
private readonly TrackedOperationId _poisonId; 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) public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
{ {
_inner = inner; _inner = inner;
_poisonId = poisonId; _poisonId = poisonId;
} }
/// <inheritdoc />
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{ {
if (siteCall.TrackedOperationId == _poisonId) if (siteCall.TrackedOperationId == _poisonId)
@@ -556,19 +605,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
} }
return _inner.UpsertAsync(siteCall, ct); return _inner.UpsertAsync(siteCall, ct);
} }
/// <inheritdoc />
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct); _inner.GetAsync(id, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCall>> QueryAsync( public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct); _inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct); _inner.PurgeTerminalAsync(olderThanUtc, ct);
/// <inheritdoc />
public Task<SiteCallKpiSnapshot> ComputeKpisAsync( public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct); _inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
/// <inheritdoc />
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>. Uses the same /// 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"/> /// exercises real <see cref="AuditLogRepository.InsertIfNotExistsAsync"/>
/// against a partitioned MSSQL schema (the only way to verify the /// against a partitioned MSSQL schema (the only way to verify the
/// IngestedAtUtc stamp + duplicate-key idempotency end to end). /// IngestedAtUtc stamp + duplicate-key idempotency end to end).
@@ -27,6 +27,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
{ {
private readonly MsSqlMigrationFixture _fixture; 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) public AuditLogIngestActorTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -61,6 +63,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
NullLogger<AuditLogIngestActor>.Instance))); 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] [SkippableFact]
public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5() 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); 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] [SkippableFact]
public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert() public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert()
{ {
@@ -126,12 +132,14 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.Equal(3, count); 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] [SkippableFact]
public async Task Receive_DuplicateEventIds_WithinOnePacket_ProduceOneRow_AndAreAllAcked() public async Task Receive_DuplicateEventIds_WithinOnePacket_ProduceOneRow_AndAreAllAcked()
{ {
Skip.IfNot(_fixture.Available, _fixture.SkipReason); 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 // only sees already-COMMITTED rows. Two copies of an EventId inside one
// packet would therefore both pass it and collide on the clustered PK, // packet would therefore both pass it and collide on the clustered PK,
// taking the whole packet down — the repository de-duplicates first. // 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); 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] [SkippableFact]
public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets() public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets()
{ {
@@ -194,6 +204,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.Equal(6, rows.Count); 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] [SkippableFact]
public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows() public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows()
{ {
@@ -225,6 +237,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.Equal(7, rows.Count); 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] [SkippableFact]
public async Task Receive_Sets_IngestedAtUtc_Before_Insert() 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] [SkippableFact]
public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted() public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted()
{ {
@@ -289,10 +305,12 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
Assert.DoesNotContain(rows, r => r.EventId == poisonId); 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] [Fact]
public async Task Receive_WhenRepositoryResolutionThrows_ActorSurvives_RepliesEmpty_CountsFailure() 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 // scoped repository per message. If scope creation / repository resolution throws
// (transient DI or DbContext-factory fault, pooled-context init, a resolution race // (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 // 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 /// source, so "not equal" IS "a fresh CTS", and a fresh CTS cannot be
/// pre-cancelled by the batch. /// pre-cancelled by the batch.
/// </remarks> /// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Receive_WhenBatchFails_PerRowFallbackUsesAFreshToken() 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> /// <summary>Counts how many times the guard's catch surfaced a write failure.</summary>
private sealed class CountingFailureCounter : ICentralAuditWriteFailureCounter private sealed class CountingFailureCounter : ICentralAuditWriteFailureCounter
{ {
/// <summary>Gets the number of failures counted so far.</summary>
public int Count { get; private set; } public int Count { get; private set; }
/// <inheritdoc />
public void Increment() => Count++; public void Increment() => Count++;
} }
@@ -382,13 +403,17 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
/// </summary> /// </summary>
private sealed class TokenRecordingRepository : IAuditLogRepository private sealed class TokenRecordingRepository : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => 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; } public CancellationToken? BatchToken { get; private set; }
/// <summary>Gets the cancellation tokens handed to each per-row insert.</summary>
public List<CancellationToken> RowTokens { get; } = new(); public List<CancellationToken> RowTokens { get; } = new();
/// <inheritdoc />
public Task<int> InsertManyIfNotExistsAsync( public Task<int> InsertManyIfNotExistsAsync(
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default) 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); throw new OperationCanceledException("simulated ingest-budget expiry", ct);
} }
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{ {
RowTokens.Add(ct); RowTokens.Add(ct);
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync( public Task<long> SwitchOutPartitionAsync(
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) => DateTime threshold, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
throw new NotSupportedException(); throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
throw new NotSupportedException(); throw new NotSupportedException();
} }
@@ -441,18 +475,23 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
/// </summary> /// </summary>
private sealed class ThrowingRepository : IAuditLogRepository private sealed class ThrowingRepository : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly IAuditLogRepository _inner; private readonly IAuditLogRepository _inner;
private readonly Guid _poisonId; 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) public ThrowingRepository(IAuditLogRepository inner, Guid poisonId)
{ {
_inner = inner; _inner = inner;
_poisonId = poisonId; _poisonId = poisonId;
} }
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{ {
if (evt.EventId == _poisonId) if (evt.EventId == _poisonId)
@@ -462,33 +501,41 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
return _inner.InsertIfNotExistsAsync(evt, ct); return _inner.InsertIfNotExistsAsync(evt, ct);
} }
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct); _inner.QueryAsync(filter, paging, ct);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct); _inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows); _inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct); _inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) => DateTime threshold, CancellationToken ct = default) =>
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct); _inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct); _inner.GetKpiSnapshotAsync(window, nowUtc, ct);
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
_inner.GetExecutionTreeAsync(executionId, ct); _inner.GetExecutionTreeAsync(executionId, ct);
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct); _inner.GetDistinctSourceNodesAsync(ct);
} }
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// </summary> /// </summary>
public class AuditLogPartitionMaintenanceOptionsValidatorTests public class AuditLogPartitionMaintenanceOptionsValidatorTests
{ {
/// <summary>Verifies that the default options pass validation.</summary>
[Fact] [Fact]
public void DefaultOptions_AreValid() public void DefaultOptions_AreValid()
{ {
@@ -17,6 +18,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests
Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded); Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded);
} }
/// <summary>Verifies that a zero interval fails validation and names the interval property.</summary>
[Fact] [Fact]
public void ZeroIntervalSeconds_Fails() public void ZeroIntervalSeconds_Fails()
{ {
@@ -28,6 +30,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal)); 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] [Fact]
public void ZeroLookaheadMonths_Fails() public void ZeroLookaheadMonths_Fails()
{ {
@@ -9,7 +9,7 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <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 — /// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
/// the real EF/MSSQL implementation is exercised by the /// the real EF/MSSQL implementation is exercised by the
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in /// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
@@ -28,6 +28,7 @@ public class AuditLogPartitionMaintenanceServiceTests
public int EnsureCallCount; public int EnsureCallCount;
public Exception? ThrowOnce; public Exception? ThrowOnce;
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default) public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
{ {
Interlocked.Increment(ref EnsureCallCount); Interlocked.Increment(ref EnsureCallCount);
@@ -39,6 +40,7 @@ public class AuditLogPartitionMaintenanceServiceTests
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>()); return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
} }
/// <inheritdoc />
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) => public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6)); Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
} }
@@ -50,12 +52,27 @@ public class AuditLogPartitionMaintenanceServiceTests
/// </summary> /// </summary>
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService> 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(); 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; 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; 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>( public void Log<TState>(
LogLevel logLevel, LogLevel logLevel,
EventId eventId, EventId eventId,
@@ -77,6 +94,8 @@ public class AuditLogPartitionMaintenanceServiceTests
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
/// <summary>Verifies that the service starts and stops without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task StartStop_NoExceptions() 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}"); 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] [Fact]
public async Task SafeMaintain_ExceptionLogged_NotPropagated() public async Task SafeMaintain_ExceptionLogged_NotPropagated()
{ {
@@ -152,10 +173,12 @@ public class AuditLogPartitionMaintenanceServiceTests
Assert.Equal(1, maintenance.EnsureCallCount); 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] [Fact]
public async Task StopAsync_AfterDispose_DoesNotThrow() 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 // StopAsync cancels, and the host does not guarantee that every
// IHostedService.StopAsync is driven before the DI container is // IHostedService.StopAsync is driven before the DI container is
// disposed — WebApplicationFactory's teardown reaches Dispose first and // 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; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <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 /// schedule-only tests substitute a recording stub for
/// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation /// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation
/// + event-publish machinery can be exercised without an MSSQL container. /// + event-publish machinery can be exercised without an MSSQL container.
/// The end-to-end "real partition gets switched out" assertion lives in the /// 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. /// actor's policy decisions.
/// </summary> /// </summary>
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture> public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{ {
private readonly MsSqlMigrationFixture _fixture; 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) public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -45,38 +47,52 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
/// </summary> /// </summary>
private sealed class RecordingRepo : IAuditLogRepository private sealed class RecordingRepo : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => 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(); 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(); 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; 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; } 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; } 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. // 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(); 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(); 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 // (channel, threshold, batchSize) so tests can assert which channels the
// actor chose to purge and with what window. // 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(); 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; public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
// The actor enumerator returns whichever list is configured here. // The actor enumerator returns whichever list is configured here.
// Mutating this between ticks lets tests simulate "no longer // Mutating this between ticks lets tests simulate "no longer
// eligible" boundaries on the second tick. // 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(); public List<DateTime> Boundaries { get; set; } = new();
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
Task.CompletedTask; Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()); Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
{ {
SwitchTimeouts.Add(commandTimeout); SwitchTimeouts.Add(commandTimeout);
@@ -88,6 +104,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
return Task.FromResult(RowsPerBoundary(monthBoundary)); return Task.FromResult(RowsPerBoundary(monthBoundary));
} }
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) DateTime threshold, CancellationToken ct = default)
{ {
@@ -95,6 +112,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray()); return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
} }
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) 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)); return Task.FromResult(RowsPerChannel(channel));
} }
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()); Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>()); Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
} }
@@ -194,6 +216,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 1. Tick_Fires_OnDailyInterval // 1. Tick_Fires_OnDailyInterval
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that the purge actor runs a purge pass on each daily interval tick.</summary>
[Fact] [Fact]
public void Tick_Fires_OnDailyInterval() public void Tick_Fires_OnDailyInterval()
{ {
@@ -216,6 +239,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 2. Tick_OldPartitions_SwitchedOut // 2. Tick_OldPartitions_SwitchedOut
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a tick switches out partitions older than the retention threshold.</summary>
[Fact] [Fact]
public void Tick_OldPartitions_SwitchedOut() public void Tick_OldPartitions_SwitchedOut()
{ {
@@ -247,6 +271,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 3. Tick_NewerPartitions_Untouched // 3. Tick_NewerPartitions_Untouched
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a tick leaves partitions newer than the retention threshold untouched.</summary>
[Fact] [Fact]
public void Tick_NewerPartitions_Untouched() public void Tick_NewerPartitions_Untouched()
{ {
@@ -276,6 +301,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 4. Tick_PublishesPurgedEvent_WithRowCount // 4. Tick_PublishesPurgedEvent_WithRowCount
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a tick publishes a purged event carrying the number of rows removed.</summary>
[Fact] [Fact]
public void Tick_PublishesPurgedEvent_WithRowCount() public void Tick_PublishesPurgedEvent_WithRowCount()
{ {
@@ -301,6 +327,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 5. Tick_SwitchThrows_OtherPartitionsStillProcessed (continue-on-error) // 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] [Fact]
public void Tick_SwitchThrows_OtherPartitionsStillProcessed() public void Tick_SwitchThrows_OtherPartitionsStillProcessed()
{ {
@@ -333,6 +360,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished // 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] [SkippableFact]
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished() public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
{ {
@@ -401,7 +430,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Options.Create(auditOptions), Options.Create(auditOptions),
NullLogger<AuditLogPurgeActor>.Instance))); 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 // 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. // 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); 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 // 7. Threshold_UsesAuditLogOptionsRetentionDays
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that the purge threshold is derived from the configured audit log retention days.</summary>
[Fact] [Fact]
public void Threshold_UsesAuditLogOptionsRetentionDays() 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] [Fact]
public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge() 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] [Fact]
public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge() 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] [Fact]
public void PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository() 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] [Fact]
public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter() public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter()
{ {
@@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <summary>
/// Task 4 tests for <see cref="AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes"/> / /// Tests for <see cref="AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes"/> /
/// <see cref="AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout"/>: the maintenance timeout /// <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 /// 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). /// cannot yield a zero/negative ADO.NET command timeout (arch-review 04, S2).
/// </summary> /// </summary>
public class AuditLogPurgeOptionsTests public class AuditLogPurgeOptionsTests
{ {
/// <summary>Verifies that the maintenance command timeout defaults to 30 minutes.</summary>
[Fact] [Fact]
public void MaintenanceCommandTimeout_Defaults_To_30_Minutes() public void MaintenanceCommandTimeout_Defaults_To_30_Minutes()
{ {
@@ -18,6 +19,9 @@ public class AuditLogPurgeOptionsTests
Assert.Equal(TimeSpan.FromMinutes(30), options.ResolvedMaintenanceCommandTimeout); 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] [Theory]
[InlineData(0, 1)] // clamp floor — a zero config value cannot become a zero timeout [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 [InlineData(-5, 1)] // clamp floor — negatives are rejected by ADO.NET
@@ -12,6 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// </summary> /// </summary>
public class AuditLogPurgeOptionsValidatorTests public class AuditLogPurgeOptionsValidatorTests
{ {
/// <summary>Verifies that the default options are valid.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void DefaultOptions_AreValid() public void DefaultOptions_AreValid()
{ {
@@ -19,6 +21,8 @@ public class AuditLogPurgeOptionsValidatorTests
Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded); Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded);
} }
/// <summary>Verifies that a zero interval in hours fails validation.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void ZeroIntervalHours_Fails() public void ZeroIntervalHours_Fails()
{ {
@@ -29,6 +33,8 @@ public class AuditLogPurgeOptionsValidatorTests
f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal)); 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] [Fact]
public void ZeroChannelPurgeBatchSize_Fails() public void ZeroChannelPurgeBatchSize_Fails()
{ {
@@ -40,6 +46,8 @@ public class AuditLogPurgeOptionsValidatorTests
f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal)); f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal));
} }
/// <summary>Verifies that a zero maintenance command timeout fails validation.</summary>
/// <returns>The result.</returns>
[Fact] [Fact]
public void ZeroMaintenanceCommandTimeout_Fails() public void ZeroMaintenanceCommandTimeout_Fails()
{ {
@@ -11,16 +11,17 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <summary>
/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor /// Coverage for the central-side payload-filter redactor
/// failure bridge. M5 wired the SITE bridge /// failure bridge. The SITE bridge
/// (<c>HealthMetricsAuditRedactionFailureCounter</c>) that pushes increments /// (<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 /// <see cref="CentralAuditRedactionFailureCounter"/> so the same payload
/// filter — when it runs on the central writer paths — surfaces failures on /// filter — when it runs on the central writer paths — surfaces failures on
/// the central <see cref="AuditCentralHealthSnapshot"/>. /// the central <see cref="AuditCentralHealthSnapshot"/>.
/// </summary> /// </summary>
public class CentralAuditRedactionFailureCounterTests : TestKit public class CentralAuditRedactionFailureCounterTests : TestKit
{ {
/// <summary>Verifies that incrementing the counter routes the failure to the central health snapshot.</summary>
[Fact] [Fact]
public void Increment_Routes_To_Snapshot() public void Increment_Routes_To_Snapshot()
{ {
@@ -34,6 +35,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
Assert.Equal(3, snapshot.AuditRedactionFailure); Assert.Equal(3, snapshot.AuditRedactionFailure);
} }
/// <summary>Verifies that constructing the counter with a null snapshot throws.</summary>
[Fact] [Fact]
public void Construction_With_Null_Snapshot_Throws() public void Construction_With_Null_Snapshot_Throws()
{ {
@@ -41,6 +43,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
() => new CentralAuditRedactionFailureCounter(null!)); () => new CentralAuditRedactionFailureCounter(null!));
} }
/// <summary>Verifies that registering central maintenance replaces the redaction failure counter with the central implementation.</summary>
[Fact] [Fact]
public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl() public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl()
{ {
@@ -72,11 +75,12 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
Assert.IsType<CentralAuditRedactionFailureCounter>(counter); Assert.IsType<CentralAuditRedactionFailureCounter>(counter);
} }
/// <summary>Verifies that the default redaction failure counter registered by the audit log is a no-op.</summary>
[Fact] [Fact]
public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp() public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp()
{ {
// Sanity check: without AddAuditLogCentralMaintenance the default // 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. // when the central-only registration runs.
var config = new ConfigurationBuilder() var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> .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; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <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 /// failure counter. <see cref="CentralAuditWriter"/> and
/// <see cref="AuditLogIngestActor"/> both swallow repository throws (audit /// <see cref="AuditLogIngestActor"/> both swallow repository throws (audit
/// must NEVER abort the user-facing action, alog.md §13) but bump the /// must NEVER abort the user-facing action, alog.md §13) but bump the
@@ -37,33 +37,43 @@ public class CentralAuditWriteFailuresTests : TestKit
/// </summary> /// </summary>
private sealed class ThrowingRepo : IAuditLogRepository private sealed class ThrowingRepo : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated repo failure"); throw new InvalidOperationException("simulated repo failure");
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()); Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) => DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>()); Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()); Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>()); Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
} }
@@ -75,10 +85,14 @@ public class CentralAuditWriteFailuresTests : TestKit
private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter
{ {
private int _count; private int _count;
/// <summary>Gets the number of increments recorded so far.</summary>
public int Count => Volatile.Read(ref _count); public int Count => Volatile.Read(ref _count);
/// <inheritdoc />
public void Increment() => Interlocked.Increment(ref _count); 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] [Fact]
public async Task Forced_Failure_Increments_Counter() public async Task Forced_Failure_Increments_Counter()
{ {
@@ -101,6 +115,8 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(1, counter.Count); 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] [Fact]
public async Task AuditLogIngestActor_Failure_Increments_Counter() public async Task AuditLogIngestActor_Failure_Increments_Counter()
{ {
@@ -128,6 +144,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(batch.Length, counter.Count); Assert.Equal(batch.Length, counter.Count);
} }
/// <summary>Verifies that the health snapshot aggregates the failure counters together with the stalled-telemetry state.</summary>
[Fact] [Fact]
public void Snapshot_Aggregates_Counters_And_StalledState() public void Snapshot_Aggregates_Counters_And_StalledState()
{ {
@@ -165,6 +182,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(1, snapshot.AuditRedactionFailure); Assert.Equal(1, snapshot.AuditRedactionFailure);
} }
/// <summary>Verifies that a newly constructed health snapshot reports empty counters.</summary>
[Fact] [Fact]
public void Snapshot_Empty_OnConstruction() public void Snapshot_Empty_OnConstruction()
{ {
@@ -178,11 +196,12 @@ public class CentralAuditWriteFailuresTests : TestKit
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// M5.3 (T7) AuditInboundCeilingHits counter // AuditInboundCeilingHits counter
// AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter. // AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter.
// Incrementing through the interface surface is reflected on the snapshot. // Incrementing through the interface surface is reflected on the snapshot.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that the inbound ceiling-hit counter starts at zero.</summary>
[Fact] [Fact]
public void AuditInboundCeilingHits_StartsAtZero() public void AuditInboundCeilingHits_StartsAtZero()
{ {
@@ -190,6 +209,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(0, snapshot.AuditInboundCeilingHits); Assert.Equal(0, snapshot.AuditInboundCeilingHits);
} }
/// <summary>Verifies that incrementing the ceiling-hit counter through its interface is reflected on the snapshot.</summary>
[Fact] [Fact]
public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot() public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot()
{ {
@@ -203,6 +223,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(3, snapshot.AuditInboundCeilingHits); Assert.Equal(3, snapshot.AuditInboundCeilingHits);
} }
/// <summary>Verifies that concurrent increments of the ceiling-hit counter are not lost.</summary>
[Fact] [Fact]
public void AuditInboundCeilingHits_IsThreadSafe() public void AuditInboundCeilingHits_IsThreadSafe()
{ {
@@ -217,6 +238,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits); Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits);
} }
/// <summary>Verifies that the ceiling-hit counter does not affect the other health counters.</summary>
[Fact] [Fact]
public void AuditInboundCeilingHits_IsIndependentOfOtherCounters() public void AuditInboundCeilingHits_IsIndependentOfOtherCounters()
{ {
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <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"/>. /// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
/// The writer is a thin wrapper around /// The writer is a thin wrapper around
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps /// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// </summary> /// </summary>
public class CentralAuditWriterTests 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) => private static AuditEvent NewEvent(Guid? eventId = null) =>
ScadaBridgeAuditEventFactory.Create( ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.Notification, channel: AuditChannel.Notification,
@@ -43,6 +43,8 @@ public class CentralAuditWriterTests
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo); 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] [Fact]
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync() public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
{ {
@@ -56,6 +58,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>()); 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] [Fact]
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert() public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
{ {
@@ -66,7 +70,7 @@ public class CentralAuditWriterTests
var after = DateTime.UtcNow; var after = DateTime.UtcNow;
await repo.Received(1).InsertIfNotExistsAsync( 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. // record — read it back via the decomposed row view.
Arg.Is<AuditEvent>(e => Arg.Is<AuditEvent>(e =>
e.AsRow().IngestedAtUtc != null && e.AsRow().IngestedAtUtc != null &&
@@ -75,6 +79,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>()); 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] [Fact]
public async Task WriteAsync_Repository_Throws_DoesNotPropagate() public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
{ {
@@ -90,6 +96,8 @@ public class CentralAuditWriterTests
await writer.WriteAsync(NewEvent()); 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] [Fact]
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope() public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
{ {
@@ -114,6 +122,7 @@ public class CentralAuditWriterTests
Assert.NotSame(instances[0], instances[1]); Assert.NotSame(instances[0], instances[1]);
} }
/// <summary>Verifies that constructing the writer with a null service provider throws.</summary>
[Fact] [Fact]
public void Constructor_NullServices_Throws() public void Constructor_NullServices_Throws()
{ {
@@ -121,6 +130,7 @@ public class CentralAuditWriterTests
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance)); () => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
} }
/// <summary>Verifies that constructing the writer with a null logger throws.</summary>
[Fact] [Fact]
public void Constructor_NullLogger_Throws() public void Constructor_NullLogger_Throws()
{ {
@@ -129,7 +139,7 @@ public class CentralAuditWriterTests
() => new CentralAuditWriter(services, null!)); () => new CentralAuditWriter(services, null!));
} }
// ----- SourceNode stamping (Task 12) ----- // // ----- SourceNode stamping ----- //
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity( private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
INodeIdentityProvider? nodeIdentity) INodeIdentityProvider? nodeIdentity)
@@ -147,6 +157,8 @@ public class CentralAuditWriterTests
return (writer, repo); 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] [Fact]
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
{ {
@@ -159,6 +171,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>()); 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] [Fact]
public async Task WriteAsync_PreservesCallerProvidedSourceNode() public async Task WriteAsync_PreservesCallerProvidedSourceNode()
{ {
@@ -172,6 +186,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>()); 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] [Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
{ {
@@ -184,11 +200,13 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>()); 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] [Fact]
public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected() public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
{ {
// Locks the back-compat contract for the optional `nodeIdentity = null` // 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 // composition roots), the writer must not stamp — caller value passes
// through unmodified. Distinct code path from // through unmodified. Distinct code path from
// "provider supplied, returns null", which the test above covers. // "provider supplied, returns null", which the test above covers.
@@ -201,6 +219,8 @@ public class CentralAuditWriterTests
Arg.Any<CancellationToken>()); 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] [Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected() 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; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <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 /// production <see cref="IPullAuditEventsClient"/> that dials a site over gRPC
/// and issues the <c>PullAuditEvents</c> unary RPC for the reconciliation loop. /// and issues the <c>PullAuditEvents</c> unary RPC for the reconciliation loop.
/// The real <c>GrpcChannel</c> is replaced by an injected /// The real <c>GrpcChannel</c> is replaced by an injected
@@ -29,7 +29,10 @@ public class GrpcPullAuditEventsClientTests
private sealed class StaticEnumerator : ISiteEnumerator private sealed class StaticEnumerator : ISiteEnumerator
{ {
private readonly IReadOnlyList<SiteEntry> _sites; 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; public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) => public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites); Task.FromResult(_sites);
} }
@@ -41,8 +44,11 @@ public class GrpcPullAuditEventsClientTests
/// </summary> /// </summary>
private sealed class FakeInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker private sealed class FakeInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
{ {
/// <summary>Gets the endpoint most recently dialed through this invoker.</summary>
public string? Endpoint { get; private set; } public string? Endpoint { get; private set; }
/// <summary>Gets the request most recently passed to this invoker.</summary>
public ProtoPullRequest? Request { get; private set; } public ProtoPullRequest? Request { get; private set; }
/// <summary>Gets the number of times this invoker has been called.</summary>
public int CallCount { get; private set; } public int CallCount { get; private set; }
private readonly ProtoPullResponse? _response; private readonly ProtoPullResponse? _response;
@@ -54,9 +60,17 @@ public class GrpcPullAuditEventsClientTests
_throw = toThrow; _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); 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); 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( public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct) string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{ {
@@ -72,17 +86,22 @@ public class GrpcPullAuditEventsClientTests
} }
/// <summary> /// <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. /// NodeA-down / NodeB-up failover can be scripted. Records dialed endpoints.
/// </summary> /// </summary>
private sealed class PerEndpointInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker private sealed class PerEndpointInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
{ {
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint; private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
/// <summary>Gets the endpoints dialed so far, in call order.</summary>
public List<string> Dialed { get; } = new(); 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) => public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
_byEndpoint = byEndpoint; _byEndpoint = byEndpoint;
// Records the dialed endpoint and returns the response scripted for it.
/// <inheritdoc />
public Task<ProtoPullResponse> InvokeAsync( public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct) string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{ {
@@ -100,6 +119,8 @@ public class GrpcPullAuditEventsClientTests
status: AuditStatus.Delivered, status: AuditStatus.Delivered,
sourceSiteId: "site-a")); 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] [Fact]
public async Task PullAsync_dials_the_resolved_endpoint_and_maps_events_oldest_first() 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); 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] [Fact]
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown() 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 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] [Theory]
[InlineData(StatusCode.Unavailable)] // connection refused / site offline [InlineData(StatusCode.Unavailable)] // connection refused / site offline
[InlineData(StatusCode.DeadlineExceeded)] // slow site / network blip [InlineData(StatusCode.DeadlineExceeded)] // slow site / network blip
@@ -167,6 +193,8 @@ public class GrpcPullAuditEventsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response() public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
{ {
@@ -184,6 +212,8 @@ public class GrpcPullAuditEventsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_swallows_unexpected_faults_to_empty_response() public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
{ {
@@ -203,6 +233,8 @@ public class GrpcPullAuditEventsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials() public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
{ {
@@ -233,10 +265,12 @@ public class GrpcPullAuditEventsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault() 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. // pull must return NodeB's events rather than collapsing to empty.
var id = Guid.NewGuid(); var id = Guid.NewGuid();
var fallbackResponse = new ProtoPullResponse { MoreAvailable = false }; var fallbackResponse = new ProtoPullResponse { MoreAvailable = false };
@@ -259,6 +293,8 @@ public class GrpcPullAuditEventsClientTests
Assert.Equal(id, evt.EventId); 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] [Fact]
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured() 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> /// <summary>
/// Tests for <see cref="GrpcPullSiteCallsClient"/> — the production /// Tests for <see cref="GrpcPullSiteCallsClient"/> — the production
/// <see cref="IPullSiteCallsClient"/> that dials a site over gRPC and issues the /// <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 /// loop. The real <c>GrpcChannel</c> is replaced by an injected
/// <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/> seam so the /// <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/> seam so the
/// client's mapping / ordering / SourceSite-restamp / fault-swallowing behaviour /// client's mapping / ordering / SourceSite-restamp / fault-swallowing behaviour
@@ -26,15 +26,21 @@ public class GrpcPullSiteCallsClientTests
private sealed class StaticEnumerator : ISiteEnumerator private sealed class StaticEnumerator : ISiteEnumerator
{ {
private readonly IReadOnlyList<SiteEntry> _sites; 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; public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) => public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites); Task.FromResult(_sites);
} }
private sealed class FakeInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker private sealed class FakeInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
{ {
/// <summary>Gets the endpoint most recently dialed.</summary>
public string? Endpoint { get; private set; } public string? Endpoint { get; private set; }
/// <summary>Gets the request most recently received.</summary>
public ProtoPullRequest? Request { get; private set; } public ProtoPullRequest? Request { get; private set; }
/// <summary>Gets the number of calls made.</summary>
public int CallCount { get; private set; } public int CallCount { get; private set; }
private readonly ProtoPullResponse? _response; private readonly ProtoPullResponse? _response;
@@ -46,9 +52,16 @@ public class GrpcPullSiteCallsClientTests
_throw = toThrow; _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); 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); public static FakeInvoker Throwing(Exception ex) => new(null, ex);
/// <inheritdoc />
public Task<ProtoPullResponse> InvokeAsync( public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct) string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{ {
@@ -64,18 +77,22 @@ public class GrpcPullSiteCallsClientTests
} }
/// <summary> /// <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 /// a NodeA-down / NodeB-up failover can be scripted. Records every dialed
/// endpoint in order. /// endpoint in order.
/// </summary> /// </summary>
private sealed class PerEndpointInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker private sealed class PerEndpointInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
{ {
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint; private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
/// <summary>Gets the endpoints dialed, in order.</summary>
public List<string> Dialed { get; } = new(); 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) => public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
_byEndpoint = byEndpoint; _byEndpoint = byEndpoint;
/// <inheritdoc />
public Task<ProtoPullResponse> InvokeAsync( public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct) string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{ {
@@ -103,6 +120,8 @@ public class GrpcPullSiteCallsClientTests
UpdatedAtUtc = Timestamp.FromDateTime(updatedAtUtc), 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] [Fact]
public async Task PullAsync_dials_resolved_endpoint_maps_oldest_first_and_restamps_source_site() 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); 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] [Fact]
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown() 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 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] [Theory]
[InlineData(StatusCode.Unavailable)] [InlineData(StatusCode.Unavailable)]
[InlineData(StatusCode.DeadlineExceeded)] [InlineData(StatusCode.DeadlineExceeded)]
@@ -178,6 +202,8 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response() public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
{ {
@@ -193,6 +219,8 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_swallows_unexpected_faults_to_empty_response() public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
{ {
@@ -208,6 +236,8 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_skips_poison_row_and_returns_the_good_rows() public async Task PullAsync_skips_poison_row_and_returns_the_good_rows()
{ {
@@ -244,6 +274,8 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials() public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
{ {
@@ -271,10 +303,12 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable); 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] [Fact]
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault() 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. // pull must return NodeB's rows rather than collapsing to empty.
var id = Guid.NewGuid(); var id = Guid.NewGuid();
var fallbackResponse = new ProtoPullResponse(); var fallbackResponse = new ProtoPullResponse();
@@ -299,6 +333,8 @@ public class GrpcPullSiteCallsClientTests
Assert.Equal("site-a", row.SourceSite); 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] [Fact]
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured() 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; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <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 /// tests substitute the <see cref="IAuditLogRepository"/> with an in-memory
/// recording stub so the actor's tick / cursor / stalled state machinery can /// recording stub so the actor's tick / cursor / stalled state machinery can
/// be exercised in milliseconds without an MSSQL container. The duplicate / /// be exercised in milliseconds without an MSSQL container. The duplicate /
/// idempotency assertion uses the real <see cref="AuditLogRepository"/> against /// idempotency assertion uses the real <see cref="AuditLogRepository"/> against
/// the <see cref="MsSqlMigrationFixture"/> so we verify InsertIfNotExistsAsync /// 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). /// reconciliation puller depends on).
/// </summary> /// </summary>
public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMigrationFixture> public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{ {
private readonly MsSqlMigrationFixture _fixture; 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) public SiteAuditReconciliationActorTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -68,13 +70,17 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// </summary> /// </summary>
private sealed class RecordingRepo : IAuditLogRepository private sealed class RecordingRepo : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => 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(); public List<AuditEvent> Inserted { get; } = new();
private readonly HashSet<Guid> _seen = 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; } public int InsertCallCount { get; private set; }
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{ {
InsertCallCount++; InsertCallCount++;
@@ -85,33 +91,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted); Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) => DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>()); Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()); Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>()); 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 /// Recording stub whose <see cref="InsertIfNotExistsAsync"/> throws for ONE
/// poison <see cref="AuditEvent.EventId"/> (every attempt) but records every /// poison <see cref="AuditEvent.EventId"/> (every attempt) but records every
/// other row — including the synthetic ReconciliationAbandoned row the actor /// 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> /// </summary>
private sealed class PoisonInsertRepo : IAuditLogRepository private sealed class PoisonInsertRepo : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly Guid _poisonId; private readonly Guid _poisonId;
private readonly Exception _fault; private readonly Exception _fault;
/// <summary>Gets the audit events recorded by this stub.</summary>
public List<AuditEvent> Inserted { get; } = new(); public List<AuditEvent> Inserted { get; } = new();
private readonly HashSet<Guid> _seen = 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) public PoisonInsertRepo(Guid poisonId, Exception fault)
{ {
_poisonId = poisonId; _poisonId = poisonId;
_fault = fault; _fault = fault;
} }
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{ {
if (evt.EventId == _poisonId) if (evt.EventId == _poisonId)
@@ -151,33 +171,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted); Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) => DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>()); Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()); Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>()); 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 /// row that also never inserts but changes EventId every pull — i.e. it never
/// individually reaches <c>MaxPermanentInsertAttempts</c>, so it keeps /// individually reaches <c>MaxPermanentInsertAttempts</c>, so it keeps
/// <c>hasUnresolvedFailure</c> true and holds the cursor at <c>since</c> forever /// <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 /// exactly like the real <c>InsertIfNotExistsAsync</c>, so a test asserting on
/// <see cref="Inserted"/>'s count is exercising the same idempotency contract /// <see cref="Inserted"/>'s count is exercising the same idempotency contract
/// production relies on. /// production relies on.
/// </summary> /// </summary>
private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => 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(); public List<AuditEvent> Inserted { get; } = new();
private readonly HashSet<Guid> _seen = new(); private readonly HashSet<Guid> _seen = new();
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{ {
if (!evt.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal)) if (!evt.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal))
@@ -217,33 +248,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted); Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L); Task.FromResult(0L);
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) => DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>()); Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()); Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>()); Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
} }
@@ -254,7 +293,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
private sealed class StaticEnumerator : ISiteEnumerator private sealed class StaticEnumerator : ISiteEnumerator
{ {
private readonly IReadOnlyList<SiteEntry> _sites; 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; public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) => public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites); Task.FromResult(_sites);
} }
@@ -266,22 +308,32 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// </summary> /// </summary>
private sealed class ScriptedPullClient : IPullAuditEventsClient 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(); 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, Queue<PullAuditEventsResponse>> _scripted = new();
private readonly Dictionary<string, Exception> _throwOnSite = 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) public ScriptedPullClient Script(string siteId, params PullAuditEventsResponse[] responses)
{ {
_scripted[siteId] = new Queue<PullAuditEventsResponse>(responses); _scripted[siteId] = new Queue<PullAuditEventsResponse>(responses);
return this; 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) public ScriptedPullClient ThrowFor(string siteId, Exception ex)
{ {
_throwOnSite[siteId] = ex; _throwOnSite[siteId] = ex;
return this; return this;
} }
/// <inheritdoc />
public Task<PullAuditEventsResponse> PullAsync( public Task<PullAuditEventsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{ {
@@ -373,6 +425,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 1. Timer_Fires_OnConfiguredInterval // 1. Timer_Fires_OnConfiguredInterval
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that the reconciliation timer fires on the configured interval.</summary>
[Fact] [Fact]
public void Timer_Fires_OnConfiguredInterval() public void Timer_Fires_OnConfiguredInterval()
{ {
@@ -400,6 +453,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 2. Tick_PullsFromEachKnownSite // 2. Tick_PullsFromEachKnownSite
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that each tick pulls from every known site.</summary>
[Fact] [Fact]
public void Tick_PullsFromEachKnownSite() public void Tick_PullsFromEachKnownSite()
{ {
@@ -426,6 +480,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync // 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that pulled events are ingested through the repository insert-if-not-exists call.</summary>
[Fact] [Fact]
public void Tick_IngestEvents_ViaInsertIfNotExistsAsync() public void Tick_IngestEvents_ViaInsertIfNotExistsAsync()
{ {
@@ -456,6 +511,8 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>() new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options); .UseSqlServer(_fixture.ConnectionString).Options);
/// <summary>Verifies that duplicate events are not inserted twice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task Tick_Duplicates_NotDoubleInserted() public async Task Tick_Duplicates_NotDoubleInserted()
{ {
@@ -509,6 +566,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 5. Cursor_Advances_ToMaxOccurredAtUtc // 5. Cursor_Advances_ToMaxOccurredAtUtc
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that the cursor advances to the maximum occurred-at timestamp of the pulled events.</summary>
[Fact] [Fact]
public void Cursor_Advances_ToMaxOccurredAtUtc() 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(DateTime.MinValue, client.Calls[0].SinceUtc);
Assert.Equal(t3, client.Calls[1].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 // 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. // it exactly instead of leaving the boundary instant permanently servable.
Assert.Null(client.Calls[0].AfterId); Assert.Null(client.Calls[0].AfterId);
Assert.Equal(e3.EventId.ToString(), client.Calls[1].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] [Fact]
public void Cursor_AdvancesOnTheIdTiebreak_WhenEveryRowSharesOneInstant() public void Cursor_AdvancesOnTheIdTiebreak_WhenEveryRowSharesOneInstant()
{ {
@@ -583,6 +642,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
Assert.Equal(high.EventId.ToString(), client.Calls[1].AfterId); 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] [Fact]
public void BothCursorHalves_AreHeldBack_WhileARowIsStillBeingRetried() 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> /// <summary>Repository whose every insert throws, so the retry hold-back path is taken.</summary>
private sealed class AlwaysThrowingRepo : IAuditLogRepository private sealed class AlwaysThrowingRepo : IAuditLogRepository
{ {
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) 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."); => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
throw new InvalidOperationException("central insert failed"); throw new InvalidOperationException("central insert failed");
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync( public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync( public Task<long> SwitchOutPartitionAsync(
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync( public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null,
CancellationToken ct = default, int maxRows = 50_000) CancellationToken ct = default, int maxRows = 50_000)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync( public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) string sentinel, DateTime before, int batchSize, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) DateTime threshold, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
} }
@@ -655,6 +725,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 6. Tick_OneSiteThrows_OtherSitesStillProcessed // 6. Tick_OneSiteThrows_OtherSitesStillProcessed
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a failure pulling from one site does not stop the other sites from being processed.</summary>
[Fact] [Fact]
public void Tick_OneSiteThrows_OtherSitesStillProcessed() public void Tick_OneSiteThrows_OtherSitesStillProcessed()
{ {
@@ -686,6 +757,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 7. StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue // 7. StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that two consecutive non-draining cycles publish a stalled state of true.</summary>
[Fact] [Fact]
public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue() public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue()
{ {
@@ -717,6 +789,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
// 8. StalledDetection_DrainingCycle_PublishesStalledFalse // 8. StalledDetection_DrainingCycle_PublishesStalledFalse
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a draining cycle publishes a stalled state of false.</summary>
[Fact] [Fact]
public void StalledDetection_DrainingCycle_PublishesStalledFalse() 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), // ReconciliationAbandoned audit record (not just a Critical log line),
// and the cursor advances past it. // and the cursor advances past it.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a permanently abandoned row emits a ReconciliationAbandoned row and advances the cursor.</summary>
[Fact] [Fact]
public void PermanentAbandonment_EmitsReconciliationAbandonedRow_AndAdvancesCursor() 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> /// <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 /// 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 /// carries a fresh <see cref="AuditEvent.EventId"/> each time (modelling a
/// site that keeps producing new events with transient insert failures). /// site that keeps producing new events with transient insert failures).
@@ -12,6 +12,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// </summary> /// </summary>
public class SiteAuditReconciliationOptionsValidatorTests 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] [Theory]
[InlineData(1, true)] [InlineData(1, true)]
[InlineData(2000, true)] [InlineData(2000, true)]
@@ -21,6 +24,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
=> Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null, => Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null,
new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded); new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded);
/// <summary>Verifies that the default options pass validation.</summary>
[Fact] [Fact]
public void DefaultOptions_AreValid() public void DefaultOptions_AreValid()
{ {
@@ -28,6 +32,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded); Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded);
} }
/// <summary>Verifies that a zero reconciliation interval fails validation.</summary>
[Fact] [Fact]
public void ZeroReconciliationInterval_Fails() public void ZeroReconciliationInterval_Fails()
{ {
@@ -39,6 +44,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal)); f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal));
} }
/// <summary>Verifies that a zero batch size fails validation.</summary>
[Fact] [Fact]
public void ZeroBatchSize_Fails() public void ZeroBatchSize_Fails()
{ {
@@ -50,6 +56,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal)); f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal));
} }
/// <summary>Verifies that a zero stalled-cycle threshold fails validation.</summary>
[Fact] [Fact]
public void ZeroStalledCycles_Fails() public void ZeroStalledCycles_Fails()
{ {
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary> /// <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 /// The tracker subscribes to the actor system's EventStream for
/// <see cref="SiteAuditTelemetryStalledChanged"/> publications and maintains a /// <see cref="SiteAuditTelemetryStalledChanged"/> publications and maintains a
/// per-site latch the central health surface can read. Since reconciliation is /// per-site latch the central health surface can read. Since reconciliation is
@@ -36,6 +36,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
interval: TimeSpan.FromMilliseconds(20)); interval: TimeSpan.FromMilliseconds(20));
} }
/// <summary>Verifies that a newly created tracker reports an empty snapshot.</summary>
[Fact] [Fact]
public void Initial_Snapshot_IsEmpty() public void Initial_Snapshot_IsEmpty()
{ {
@@ -46,6 +47,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
Assert.Empty(snapshot); Assert.Empty(snapshot);
} }
/// <summary>Verifies that a stalled event makes the tracker report the site as stalled.</summary>
[Fact] [Fact]
public void StalledTrue_Event_TrackerReports_Stalled() public void StalledTrue_Event_TrackerReports_Stalled()
{ {
@@ -57,6 +59,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
Assert.True(snapshot["siteA"]); Assert.True(snapshot["siteA"]);
} }
/// <summary>Verifies that a not-stalled event makes the tracker report the site as not stalled.</summary>
[Fact] [Fact]
public void StalledFalse_Event_TrackerReports_NotStalled() public void StalledFalse_Event_TrackerReports_NotStalled()
{ {
@@ -72,6 +75,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
Assert.False(snapshot["siteA"]); Assert.False(snapshot["siteA"]);
} }
/// <summary>Verifies that multiple sites are tracked independently of one another.</summary>
[Fact] [Fact]
public void Multiple_Sites_Tracked_Independently() public void Multiple_Sites_Tracked_Independently()
{ {
@@ -88,6 +92,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
Assert.True(snapshot["siteC"]); Assert.True(snapshot["siteC"]);
} }
/// <summary>Verifies that constructing the tracker with a null actor system throws.</summary>
[Fact] [Fact]
public void Constructor_With_Null_ActorSystem_Throws() public void Constructor_With_Null_ActorSystem_Throws()
{ {
@@ -95,6 +100,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
() => new SiteAuditTelemetryStalledTracker((ActorSystem)null!)); () => new SiteAuditTelemetryStalledTracker((ActorSystem)null!));
} }
/// <summary>Verifies that disposing the tracker unsubscribes it from the event stream.</summary>
[Fact] [Fact]
public void Dispose_Unsubscribes_From_EventStream() public void Dispose_Unsubscribes_From_EventStream()
{ {
@@ -41,6 +41,8 @@ public class SiteEnumeratorTests
return services.BuildServiceProvider(); 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] [Fact]
public async Task EnumerateAsync_ProjectsSitesWithNodeAAddress_AndSkipsBlankOnes() public async Task EnumerateAsync_ProjectsSitesWithNodeAAddress_AndSkipsBlankOnes()
{ {
@@ -60,6 +62,8 @@ public class SiteEnumeratorTests
Assert.Equal("http://site-a:8083", entry.GrpcEndpoint); 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] [Fact]
public async Task EnumerateAsync_SkipsNullNodeAAddress() public async Task EnumerateAsync_SkipsNullNodeAAddress()
{ {
@@ -76,10 +80,12 @@ public class SiteEnumeratorTests
Assert.Empty(result); 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] [Fact]
public async Task EnumerateAsync_BothAddresses_ProjectsNodeBAsFallback() 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). // SiteEntry(primary=NodeA, FallbackGrpcEndpoint=NodeB).
var repository = Substitute.For<ISiteRepository>(); var repository = Substitute.For<ISiteRepository>();
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity> repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
@@ -95,10 +101,12 @@ public class SiteEnumeratorTests
Assert.Equal("http://site-b:8083", entry.FallbackGrpcEndpoint); 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] [Fact]
public async Task EnumerateAsync_OnlyNodeB_IsNotSkipped_AndHasNoFallback() 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. // skipped — NodeB becomes the primary and there is no separate fallback.
var repository = Substitute.For<ISiteRepository>(); var repository = Substitute.For<ISiteRepository>();
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity> repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
@@ -114,6 +122,8 @@ public class SiteEnumeratorTests
Assert.Null(entry.FallbackGrpcEndpoint); 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] [Fact]
public async Task EnumerateAsync_ReturnsEmpty_WhenNoSites() public async Task EnumerateAsync_ReturnsEmpty_WhenNoSites()
{ {
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
/// <summary> /// <summary>
/// Bundle D (M5-T8) tests for hot-reloadable <see cref="AuditLogOptions"/> /// Tests for hot-reloadable <see cref="AuditLogOptions"/>
/// binding. The first test pins the JSON-realistic binding shape end-to-end /// binding. The first test pins the JSON-realistic binding shape end-to-end
/// (scalars, lists, per-target overrides) so accidental drift in the section /// (scalars, lists, per-target overrides) so accidental drift in the section
/// layout breaks the build. The second test exercises the live hot-reload /// layout breaks the build. The second test exercises the live hot-reload
@@ -23,13 +23,14 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
/// flowing through without a restart. /// flowing through without a restart.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Distinct from <see cref="AuditLogOptionsTests"/> (M1-T9) which covered /// Distinct from <see cref="AuditLogOptionsTests"/> which covered
/// section binding + validator failures via single-key in-memory config — those /// section binding + validator failures via single-key in-memory config — those
/// tests exist; these add (a) end-to-end binding from a realistic JSON literal /// tests exist; these add (a) end-to-end binding from a realistic JSON literal
/// and (b) the hot-reload behavioural contract the M5-T8 spec calls out. /// and (b) the hot-reload behavioural contract the spec calls out.
/// </remarks> /// </remarks>
public class AuditLogOptionsBindingTests public class AuditLogOptionsBindingTests
{ {
/// <summary>Verifies that the AuditLog configuration section binds every field.</summary>
[Fact] [Fact]
public void AuditLog_Section_Binds_AllFields() public void AuditLog_Section_Binds_AllFields()
{ {
@@ -71,7 +72,7 @@ public class AuditLogOptionsBindingTests
// HeaderRedactList: the Microsoft.Extensions.Configuration list binder // HeaderRedactList: the Microsoft.Extensions.Configuration list binder
// APPENDS to the default list, so we assert containment rather than // APPENDS to the default list, so we assert containment rather than
// exact equality (see M1-T9 AuditLogOptionsTests for the rationale). // exact equality (see AuditLogOptionsTests for the rationale).
Assert.Contains("Authorization", opts.HeaderRedactList); Assert.Contains("Authorization", opts.HeaderRedactList);
Assert.Contains("Custom-Token", opts.HeaderRedactList); Assert.Contains("Custom-Token", opts.HeaderRedactList);
@@ -90,10 +91,11 @@ public class AuditLogOptionsBindingTests
Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching); Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching);
} }
/// <summary>Verifies that the purge options bind from the documented section and keys.</summary>
[Fact] [Fact]
public void PurgeOptions_Bind_FromDocumentedSectionAndKeys() public void PurgeOptions_Bind_FromDocumentedSectionAndKeys()
{ {
// AuditLog-013: the design doc (Component-AuditLog.md §Configuration) // The design doc (Component-AuditLog.md §Configuration)
// documents the purge tuning as the nested `AuditLog:Purge` section with // documents the purge tuning as the nested `AuditLog:Purge` section with
// keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the // keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the
// code binds from EXACTLY that shape — the section path the production // code binds from EXACTLY that shape — the section path the production
@@ -139,6 +141,7 @@ public class AuditLogOptionsBindingTests
Assert.Equal(1000, opts.ChannelPurgeBatchSize); Assert.Equal(1000, opts.ChannelPurgeBatchSize);
} }
/// <summary>Verifies that the filter behavior updates when the configuration reloads.</summary>
[Fact] [Fact]
public void Filter_Behavior_Updates_OnConfigReload() public void Filter_Behavior_Updates_OnConfigReload()
{ {
@@ -165,7 +168,7 @@ public class AuditLogOptionsBindingTests
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096); Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);
// Reload: cap raised to 16384 — next event must NOT truncate. This is // Reload: cap raised to 16384 — next event must NOT truncate. This is
// the M5-T8 contract: the filter sees the new value on the very next // the hot-reload contract: the filter sees the new value on the very next
// Apply, without process restart. // Apply, without process restart.
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 }); monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
@@ -174,6 +177,7 @@ public class AuditLogOptionsBindingTests
Assert.Equal(body, resultAfter.AsRow().RequestSummary); Assert.Equal(body, resultAfter.AsRow().RequestSummary);
} }
/// <summary>Verifies that the filter picks up a new body redactor when the configuration reloads.</summary>
[Fact] [Fact]
public void Filter_PicksUp_NewBodyRedactor_OnConfigReload() public void Filter_PicksUp_NewBodyRedactor_OnConfigReload()
{ {
@@ -219,12 +223,21 @@ public class AuditLogOptionsBindingTests
private T _current; private T _current;
private readonly List<Action<T, string?>> _listeners = new(); private readonly List<Action<T, string?>> _listeners = new();
/// <summary>Initializes a new instance of the <c>TestOptionsMonitor</c> class.</summary>
/// <param name="initial">The initial.</param>
public TestOptionsMonitor(T initial) => _current = initial; public TestOptionsMonitor(T initial) => _current = initial;
/// <summary>Gets the current value value.</summary>
public T CurrentValue => _current; public T CurrentValue => _current;
/// <summary>Returns the current options value regardless of the name.</summary>
/// <param name="name">The options name; ignored.</param>
/// <returns>The current options value.</returns>
public T Get(string? name) => _current; public T Get(string? name) => _current;
/// <summary>Registers a listener to be notified when the options change.</summary>
/// <param name="listener">The change callback.</param>
/// <returns>A disposable that removes the listener, or null when none is tracked.</returns>
public IDisposable? OnChange(Action<T, string?> listener) public IDisposable? OnChange(Action<T, string?> listener)
{ {
lock (_listeners) lock (_listeners)
@@ -234,6 +247,8 @@ public class AuditLogOptionsBindingTests
return new Unsubscribe(_listeners, listener); return new Unsubscribe(_listeners, listener);
} }
/// <summary>Replaces the current value and notifies registered listeners.</summary>
/// <param name="value">The value.</param>
public void Set(T value) public void Set(T value)
{ {
_current = value; _current = value;
@@ -252,11 +267,15 @@ public class AuditLogOptionsBindingTests
{ {
private readonly List<Action<T, string?>> _listeners; private readonly List<Action<T, string?>> _listeners;
private readonly Action<T, string?> _listener; private readonly Action<T, string?> _listener;
/// <summary>Initializes a new instance of the <c>Unsubscribe</c> class.</summary>
/// <param name="listeners">The listeners.</param>
/// <param name="listener">The listener.</param>
public Unsubscribe(List<Action<T, string?>> listeners, Action<T, string?> listener) public Unsubscribe(List<Action<T, string?>> listeners, Action<T, string?> listener)
{ {
_listeners = listeners; _listeners = listeners;
_listener = listener; _listener = listener;
} }
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose() public void Dispose()
{ {
lock (_listeners) lock (_listeners)
@@ -6,8 +6,8 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
/// <summary> /// <summary>
/// Task 9 (Bundle E): <see cref="AuditLogOptions"/> binding + validator /// Tests for <see cref="AuditLogOptions"/> binding and validator
/// behavior. The validator enforces invariants used by M2+ writers /// behavior. The validator enforces invariants used by the audit writers
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>): /// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
/// <c>DefaultCapBytes &gt; 0</c>, <c>ErrorCapBytes &gt;= DefaultCapBytes</c>, /// <c>DefaultCapBytes &gt; 0</c>, <c>ErrorCapBytes &gt;= DefaultCapBytes</c>,
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the /// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
@@ -25,6 +25,7 @@ public class AuditLogOptionsTests
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>(); return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
} }
/// <summary>Verifies that a fully populated configuration section binds every scalar field onto the options.</summary>
[Fact] [Fact]
public void ValidBinding_PopulatesAllScalarFields() public void ValidBinding_PopulatesAllScalarFields()
{ {
@@ -40,6 +41,7 @@ public class AuditLogOptionsTests
Assert.Equal(180, opts.RetentionDays); Assert.Equal(180, opts.RetentionDays);
} }
/// <summary>Verifies that an empty configuration section yields reasonable default option values.</summary>
[Fact] [Fact]
public void DefaultsAreReasonable_WhenSectionEmpty() public void DefaultsAreReasonable_WhenSectionEmpty()
{ {
@@ -56,6 +58,7 @@ public class AuditLogOptionsTests
Assert.Empty(opts.PerTargetOverrides); Assert.Empty(opts.PerTargetOverrides);
} }
/// <summary>Verifies that header redaction entries bound from configuration are appended to the default list.</summary>
[Fact] [Fact]
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults() public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
{ {
@@ -77,6 +80,7 @@ public class AuditLogOptionsTests
Assert.Contains("X-Api-Key", opts.HeaderRedactList); Assert.Contains("X-Api-Key", opts.HeaderRedactList);
} }
/// <summary>Verifies that per-target override settings bind from configuration.</summary>
[Fact] [Fact]
public void PerTargetOverrides_BindsFromConfig() public void PerTargetOverrides_BindsFromConfig()
{ {
@@ -93,6 +97,7 @@ public class AuditLogOptionsTests
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!); Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
} }
/// <summary>Verifies that an invalid default payload cap fails validation.</summary>
[Fact] [Fact]
public void InvalidDefaultCapBytes_FailsValidation() public void InvalidDefaultCapBytes_FailsValidation()
{ {
@@ -104,6 +109,7 @@ public class AuditLogOptionsTests
Assert.Contains("DefaultCapBytes", result.FailureMessage); Assert.Contains("DefaultCapBytes", result.FailureMessage);
} }
/// <summary>Verifies that an invalid error payload cap fails validation.</summary>
[Fact] [Fact]
public void InvalidErrorCapBytes_FailsValidation() public void InvalidErrorCapBytes_FailsValidation()
{ {
@@ -115,6 +121,7 @@ public class AuditLogOptionsTests
Assert.Contains("ErrorCapBytes", result.FailureMessage); Assert.Contains("ErrorCapBytes", result.FailureMessage);
} }
/// <summary>Verifies that a retention period below the minimum fails validation.</summary>
[Fact] [Fact]
public void RetentionDaysBelowMinimum_FailsValidation() public void RetentionDaysBelowMinimum_FailsValidation()
{ {
@@ -126,6 +133,7 @@ public class AuditLogOptionsTests
Assert.Contains("RetentionDays", result.FailureMessage); Assert.Contains("RetentionDays", result.FailureMessage);
} }
/// <summary>Verifies that a retention period above the maximum fails validation.</summary>
[Fact] [Fact]
public void RetentionDaysAboveMaximum_FailsValidation() public void RetentionDaysAboveMaximum_FailsValidation()
{ {
@@ -137,6 +145,7 @@ public class AuditLogOptionsTests
Assert.Contains("RetentionDays", result.FailureMessage); Assert.Contains("RetentionDays", result.FailureMessage);
} }
/// <summary>Verifies that the default options pass validation.</summary>
[Fact] [Fact]
public void DefaultOptions_PassValidation() public void DefaultOptions_PassValidation()
{ {
@@ -147,6 +156,7 @@ public class AuditLogOptionsTests
Assert.True(result.Succeeded, result.FailureMessage); Assert.True(result.Succeeded, result.FailureMessage);
} }
/// <summary>Verifies that an invalid retention value bound from configuration is rejected when the options value is accessed.</summary>
[Fact] [Fact]
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess() public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
{ {
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
/// <summary> /// <summary>
/// Task 1 of <c>docs/plans/2026-05-23-inbound-api-full-response-audit.md</c>: /// Per <c>docs/plans/2026-05-23-inbound-api-full-response-audit.md</c>, this class
/// pins the <see cref="AuditLogOptions.InboundMaxBytes"/> default to 1 MiB and /// pins the <see cref="AuditLogOptions.InboundMaxBytes"/> default to 1 MiB and
/// the validator bounds to <c>[8 KiB, 16 MiB]</c>. The inbound channel needs a /// the validator bounds to <c>[8 KiB, 16 MiB]</c>. The inbound channel needs a
/// much larger ceiling than the 8 KiB / 64 KiB default/error caps that other /// much larger ceiling than the 8 KiB / 64 KiB default/error caps that other
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
/// </summary> /// </summary>
public class AuditLogOptionsValidatorTests public class AuditLogOptionsValidatorTests
{ {
/// <summary>Verifies that the default options set the inbound maximum body size to one mebibyte.</summary>
[Fact] [Fact]
public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte() public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
{ {
@@ -24,6 +25,8 @@ public class AuditLogOptionsValidatorTests
Assert.Equal(1_048_576, opts.InboundMaxBytes); Assert.Equal(1_048_576, opts.InboundMaxBytes);
} }
/// <summary>Verifies that an inbound maximum body size within the documented range passes validation.</summary>
/// <param name="value">The inbound maximum body size in bytes.</param>
[Theory] [Theory]
[InlineData(8_192)] // documented min [InlineData(8_192)] // documented min
[InlineData(1_048_576)] // default [InlineData(1_048_576)] // default
@@ -35,6 +38,8 @@ public class AuditLogOptionsValidatorTests
Assert.True(validator.Validate(null, opts).Succeeded); Assert.True(validator.Validate(null, opts).Succeeded);
} }
/// <summary>Verifies that an inbound maximum body size outside the documented range fails validation.</summary>
/// <param name="value">The inbound maximum body size in bytes.</param>
[Theory] [Theory]
[InlineData(0)] [InlineData(0)]
[InlineData(8_191)] [InlineData(8_191)]
@@ -52,9 +57,10 @@ public class AuditLogOptionsValidatorTests
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// M5.5 (T3) per-channel retention overrides // Per-channel retention overrides
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a per-channel retention shorter than the global retention passes validation.</summary>
[Fact] [Fact]
public void Validate_PerChannelRetention_ShorterThanGlobal_Passes() public void Validate_PerChannelRetention_ShorterThanGlobal_Passes()
{ {
@@ -75,6 +81,7 @@ public class AuditLogOptionsValidatorTests
Assert.True(validator.Validate(null, opts).Succeeded); Assert.True(validator.Validate(null, opts).Succeeded);
} }
/// <summary>Verifies that a per-channel retention equal to the global retention passes validation.</summary>
[Fact] [Fact]
public void Validate_PerChannelRetention_EqualToGlobal_Passes() public void Validate_PerChannelRetention_EqualToGlobal_Passes()
{ {
@@ -90,6 +97,7 @@ public class AuditLogOptionsValidatorTests
Assert.True(validator.Validate(null, opts).Succeeded); Assert.True(validator.Validate(null, opts).Succeeded);
} }
/// <summary>Verifies that a per-channel retention longer than the global retention fails validation.</summary>
[Fact] [Fact]
public void Validate_PerChannelRetention_LongerThanGlobal_Fails() public void Validate_PerChannelRetention_LongerThanGlobal_Fails()
{ {
@@ -110,6 +118,7 @@ public class AuditLogOptionsValidatorTests
&& f.Contains("ApiInbound", StringComparison.Ordinal)); && f.Contains("ApiInbound", StringComparison.Ordinal));
} }
/// <summary>Verifies that a per-channel retention below the minimum fails validation.</summary>
[Fact] [Fact]
public void Validate_PerChannelRetention_BelowMinimum_Fails() public void Validate_PerChannelRetention_BelowMinimum_Fails()
{ {
@@ -127,6 +136,7 @@ public class AuditLogOptionsValidatorTests
f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal)); f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal));
} }
/// <summary>Verifies that a per-channel retention keyed by an unknown channel fails validation.</summary>
[Fact] [Fact]
public void Validate_PerChannelRetention_UnknownChannelKey_Fails() public void Validate_PerChannelRetention_UnknownChannelKey_Fails()
{ {
@@ -146,6 +156,7 @@ public class AuditLogOptionsValidatorTests
f => f.Contains("NotAChannel", StringComparison.Ordinal)); f => f.Contains("NotAChannel", StringComparison.Ordinal));
} }
/// <summary>Verifies that the default empty per-channel retention map passes validation.</summary>
[Fact] [Fact]
public void Validate_PerChannelRetention_DefaultEmpty_Passes() public void Validate_PerChannelRetention_DefaultEmpty_Passes()
{ {
@@ -36,17 +36,16 @@ using System.Text.Encodings.Web;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Audit Log #23 — M4 Bundle E (Task E4) cross-boundary safety suite verifying /// Cross-boundary safety suite verifying
/// the alog.md §13 contract: an always-throwing audit writer NEVER aborts the /// the alog.md §13 contract: an always-throwing audit writer NEVER aborts the
/// user-facing action. Exercises every boundary that emits audit rows in M2, /// user-facing action. Exercises every boundary that emits audit rows:
/// M3, and M4:
/// ///
/// <list type="bullet"> /// <list type="bullet">
/// <item><description>External system sync call (M2 Bundle F).</description></item> /// <item><description>External system sync call (sync).</description></item>
/// <item><description>External system cached call (M3 Bundle E).</description></item> /// <item><description>External system cached call (cached).</description></item>
/// <item><description>Database sync write (M4 Bundle A).</description></item> /// <item><description>Database sync write (sync write).</description></item>
/// <item><description>Inbound API request (M4 Bundle D).</description></item> /// <item><description>Inbound API request (request).</description></item>
/// <item><description>Notification dispatcher (M4 Bundle B).</description></item> /// <item><description>Notification dispatcher (dispatch).</description></item>
/// </list> /// </list>
/// ///
/// <para> /// <para>
@@ -64,6 +63,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared migrated MS SQL database fixture.</param>
public AuditWriteFailureSafetyTests(MsSqlMigrationFixture fixture) public AuditWriteFailureSafetyTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -81,8 +82,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
private sealed class ThrowingAuditWriter : IAuditWriter private sealed class ThrowingAuditWriter : IAuditWriter
{ {
private int _attempts; private int _attempts;
/// <summary>Gets the number of times <c>WriteAsync</c> has been attempted.</summary>
public int Attempts => Volatile.Read(ref _attempts); public int Attempts => Volatile.Read(ref _attempts);
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{ {
Interlocked.Increment(ref _attempts); Interlocked.Increment(ref _attempts);
@@ -100,8 +103,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
{ {
private int _attempts; private int _attempts;
/// <summary>Gets the number of times <c>WriteAsync</c> has been attempted.</summary>
public int Attempts => Volatile.Read(ref _attempts); public int Attempts => Volatile.Read(ref _attempts);
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{ {
Interlocked.Increment(ref _attempts); Interlocked.Increment(ref _attempts);
@@ -118,8 +123,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
private sealed class ThrowingCachedForwarder : ICachedCallTelemetryForwarder private sealed class ThrowingCachedForwarder : ICachedCallTelemetryForwarder
{ {
private int _attempts; private int _attempts;
/// <summary>Gets the number of times <c>ForwardAsync</c> has been attempted.</summary>
public int Attempts => Volatile.Read(ref _attempts); public int Attempts => Volatile.Read(ref _attempts);
/// <inheritdoc />
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{ {
Interlocked.Increment(ref _attempts); Interlocked.Increment(ref _attempts);
@@ -132,6 +139,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
// Test 1 — ESG sync call still returns the original ExternalCallResult. // Test 1 — ESG sync call still returns the original ExternalCallResult.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a synchronous external system call still returns its result when the audit writer throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task EsgSyncCall_BrokenAuditWriter_StillReturnsResult() public async Task EsgSyncCall_BrokenAuditWriter_StillReturnsResult()
{ {
@@ -171,6 +180,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
// Test 2 — ESG cached call still returns a TrackedOperationId. // Test 2 — ESG cached call still returns a TrackedOperationId.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a cached external system call still returns a tracked operation id when the audit writer throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task EsgCachedCall_BrokenAuditWriter_StillReturnsTrackedOperationId() public async Task EsgCachedCall_BrokenAuditWriter_StillReturnsTrackedOperationId()
{ {
@@ -214,6 +225,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
// Test 3 — DB sync write still returns the rows-affected count. // Test 3 — DB sync write still returns the rows-affected count.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a synchronous database write still returns the rows-affected count when the audit writer throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DbSyncWrite_BrokenAuditWriter_StillReturnsRowsAffected() public async Task DbSyncWrite_BrokenAuditWriter_StillReturnsRowsAffected()
{ {
@@ -269,6 +282,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
// Test 4 — Inbound API request still returns HTTP 200. // Test 4 — Inbound API request still returns HTTP 200.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that an inbound API request still returns HTTP 200 when the audit writer throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task InboundApi_BrokenAuditWriter_StillReturns200() public async Task InboundApi_BrokenAuditWriter_StillReturns200()
{ {
@@ -290,6 +305,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
// Test 5 — Notification dispatcher still transitions to Delivered. // Test 5 — Notification dispatcher still transitions to Delivered.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a notification dispatch still transitions to delivered when the audit writer throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered() public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered()
{ {
@@ -381,7 +398,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
private async Task SeedSmtpConfigAsync() private async Task SeedSmtpConfigAsync()
{ {
await using var ctx = CreateContext(); await using var ctx = CreateContext();
// NO-002: dispatcher clamps non-positive RetryDelay to the 1-minute fallback; // The dispatcher clamps non-positive RetryDelay to the 1-minute fallback;
// use 1 ms so a transient outcome's NextAttemptAt is still effectively due. // use 1 ms so a transient outcome's NextAttemptAt is still effectively due.
ctx.SmtpConfigurations.Add(new SmtpConfiguration( ctx.SmtpConfigurations.Add(new SmtpConfiguration(
"smtp.example.com", "Basic", "noreply@example.com") "smtp.example.com", "Basic", "noreply@example.com")
@@ -418,8 +435,12 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
private sealed class SingleOutcomeAdapter : INotificationDeliveryAdapter private sealed class SingleOutcomeAdapter : INotificationDeliveryAdapter
{ {
private readonly DeliveryOutcome _outcome; private readonly DeliveryOutcome _outcome;
/// <summary>Initializes the adapter with the outcome to return for every delivery.</summary>
/// <param name="outcome">The delivery outcome returned by every call.</param>
public SingleOutcomeAdapter(DeliveryOutcome outcome) { _outcome = outcome; } public SingleOutcomeAdapter(DeliveryOutcome outcome) { _outcome = outcome; }
/// <inheritdoc />
public NotificationType Type => NotificationType.Email; public NotificationType Type => NotificationType.Email;
/// <inheritdoc />
public Task<DeliveryOutcome> DeliverAsync( public Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default) Notification notification, CancellationToken cancellationToken = default)
=> Task.FromResult(_outcome); => Task.FromResult(_outcome);
@@ -472,12 +493,17 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
: Microsoft.AspNetCore.Authentication.AuthenticationHandler< : Microsoft.AspNetCore.Authentication.AuthenticationHandler<
Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions>
{ {
/// <summary>Initializes the handler with the standard authentication handler dependencies.</summary>
/// <param name="options">The authentication scheme options monitor.</param>
/// <param name="logger">The logger factory.</param>
/// <param name="encoder">The URL encoder.</param>
public AlwaysAuthenticatedHandler( public AlwaysAuthenticatedHandler(
IOptionsMonitor<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> options, IOptionsMonitor<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> options,
Microsoft.Extensions.Logging.ILoggerFactory logger, Microsoft.Extensions.Logging.ILoggerFactory logger,
UrlEncoder encoder) UrlEncoder encoder)
: base(options, logger, encoder) { } : base(options, logger, encoder) { }
/// <inheritdoc />
protected override Task<Microsoft.AspNetCore.Authentication.AuthenticateResult> protected override Task<Microsoft.AspNetCore.Authentication.AuthenticateResult>
HandleAuthenticateAsync() HandleAuthenticateAsync()
{ {
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Bundle G G2 end-to-end suite for cached <c>ExternalSystem.CachedCall</c> /// Bundle G G2 end-to-end suite for cached <c>ExternalSystem.CachedCall</c>
/// lifecycle telemetry (Audit Log #23 / M3). Wires the full M3 pipeline: /// lifecycle telemetry. Wires the full pipeline:
/// site-local SQLite audit writer + operation tracking store + the production /// site-local SQLite audit writer + operation tracking store + the production
/// <see cref="CachedCallTelemetryForwarder"/> + the test-side /// <see cref="CachedCallTelemetryForwarder"/> + the test-side
/// <see cref="CombinedTelemetryDispatcher"/> that ALSO pushes each combined /// <see cref="CombinedTelemetryDispatcher"/> that ALSO pushes each combined
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <para> /// <para>
/// The bridge is driven directly via <see cref="CombinedTelemetryHarness.EmitAttemptAsync"/> /// The bridge is driven directly via <see cref="CombinedTelemetryHarness.EmitAttemptAsync"/>
/// — these tests do NOT spin up the actual S&amp;F retry loop; that would /// — these tests do NOT spin up the actual S&amp;F retry loop; that would
/// require a full SiteRuntime host and is out of scope for M3 (the S&amp;F /// require a full SiteRuntime host and is out of scope here (the S&amp;F
/// observer hooks are exercised in <c>ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests</c> at /// observer hooks are exercised in <c>ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests</c> at
/// unit level). The submit row is emitted via /// unit level). The submit row is emitted via
/// <see cref="CombinedTelemetryHarness.EmitSubmitAsync"/> because the /// <see cref="CombinedTelemetryHarness.EmitSubmitAsync"/> because the
@@ -45,6 +45,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test with the shared SQL Server migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public CachedCallCombinedTelemetryTests(MsSqlMigrationFixture fixture) public CachedCallCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -106,6 +108,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
DurationMs: 42, DurationMs: 42,
SourceInstanceId: "Plant.Pump42"); SourceInstanceId: "Plant.Pump42");
/// <summary>Verifies that two failed attempts followed by a success emit five audit rows and one delivered site call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task CachedCall_FailFailSuccess_Emits_5_AuditRows_AND_1_SiteCall_Delivered() public async Task CachedCall_FailFailSuccess_Emits_5_AuditRows_AND_1_SiteCall_Delivered()
{ {
@@ -177,6 +181,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
Assert.NotNull(snapshot.TerminalAtUtc); Assert.NotNull(snapshot.TerminalAtUtc);
} }
/// <summary>Verifies that a cached call whose attempts all fail and are parked emits a terminal Parked row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked() public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked()
{ {
@@ -227,6 +233,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
Assert.NotNull(snapshot.TerminalAtUtc); Assert.NotNull(snapshot.TerminalAtUtc);
} }
/// <summary>Verifies that an immediate success without store-and-forward emits an attempted row and resolves as delivered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered() public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered()
{ {
@@ -34,6 +34,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared SQL Server migration fixture.</summary>
/// <param name="fixture">The shared SQL Server migration fixture.</param>
public CachedWriteCombinedTelemetryTests(MsSqlMigrationFixture fixture) public CachedWriteCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -93,6 +95,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
DurationMs: 12, DurationMs: 12,
SourceInstanceId: "Plant.Pump42"); SourceInstanceId: "Plant.Pump42");
/// <summary>Verifies that a successful cached write emits the full lifecycle ending in a delivered row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task CachedWrite_Success_Emits_Delivered_Lifecycle() public async Task CachedWrite_Success_Emits_Delivered_Lifecycle()
{ {
@@ -144,6 +148,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
Assert.Equal("Delivered", snapshot!.Status); Assert.Equal("Delivered", snapshot!.Status);
} }
/// <summary>Verifies that a parked cached write emits a terminal parked row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task CachedWrite_Parked_Emits_Terminal_Parked() public async Task CachedWrite_Parked_Emits_Terminal_Parked()
{ {
@@ -34,6 +34,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared migrated MS SQL database fixture.</param>
public CombinedTelemetryIdempotencyTests(MsSqlMigrationFixture fixture) public CombinedTelemetryIdempotencyTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -100,6 +102,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
return batch; return batch;
} }
/// <summary>Verifies that a duplicate telemetry packet leaves a single audit row and upserts the site call monotonically.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task DuplicatePacket_AuditLogStaysAtOneRow_SiteCallsUpserted_Monotonically() public async Task DuplicatePacket_AuditLogStaysAtOneRow_SiteCallsUpserted_Monotonically()
{ {
@@ -144,6 +148,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
Assert.Equal(0, siteCalls[0].RetryCount); Assert.Equal(0, siteCalls[0].RetryCount);
} }
/// <summary>Verifies that an older status arriving after a newer one is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task OutOfOrderPackets_OlderStatus_ArrivesAfterNewer_IsNoOp() public async Task OutOfOrderPackets_OlderStatus_ArrivesAfterNewer_IsNoOp()
{ {
@@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Audit Log #23 — M4 Bundle E (Task E1) end-to-end suite verifying every /// End-to-end suite verifying every
/// synchronous <c>Database.Connection(name).Execute*</c> / /// synchronous <c>Database.Connection(name).Execute*</c> /
/// <c>ExecuteReader</c> call made via the Bundle A /// <c>ExecuteReader</c> call made via the Bundle A
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/> emits exactly one /// <see cref="ScriptRuntimeContext.DatabaseHelper"/> emits exactly one
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>: /// Composes the same pipeline as the <see cref="SyncCallEmissionEndToEndTests"/>:
/// in-memory <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> + /// in-memory <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real /// <see cref="FallbackAuditWriter"/> on the site, drained by a real
/// <see cref="SiteAuditTelemetryActor"/> through a /// <see cref="SiteAuditTelemetryActor"/> through a
@@ -57,6 +57,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared migrated MS SQL database fixture.</param>
public DatabaseSyncEmissionEndToEndTests(MsSqlMigrationFixture fixture) public DatabaseSyncEmissionEndToEndTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -170,6 +172,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
sourceScript: SourceScript, sourceScript: SourceScript,
cachedForwarder: null); cachedForwarder: null);
/// <summary>Verifies that a database insert emits one central audit row carrying a write operation marker and the rows-affected count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task DbWrite_Insert_Emits_OneCentralRow_WithExtraOpWrite_AndRowsAffected() public async Task DbWrite_Insert_Emits_OneCentralRow_WithExtraOpWrite_AndRowsAffected()
{ {
@@ -240,6 +244,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
}, TimeSpan.FromSeconds(15)); }, TimeSpan.FromSeconds(15));
} }
/// <summary>Verifies that a database select emits one central audit row carrying a read operation marker and the rows-returned count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task DbWrite_Select_Emits_OneCentralRow_WithExtraOpRead_AndRowsReturned() public async Task DbWrite_Select_Emits_OneCentralRow_WithExtraOpRead_AndRowsReturned()
{ {
@@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the /// ExecutionId end-to-end correlation suite verifying the
/// universal per-run correlation promise: <b>every audit row produced by one /// universal per-run correlation promise: <b>every audit row produced by one
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>. /// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
/// </summary> /// </summary>
@@ -39,8 +39,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// reads the rows back from the central store and asserts the shared id. /// reads the rows back from the central store and asserts the shared id.
/// </para> /// </para>
/// <para> /// <para>
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/> /// Composes the same pipeline as the sync-call <see cref="SyncCallEmissionEndToEndTests"/>
/// and the M4 <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory /// and the database-write <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> + /// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real /// <see cref="FallbackAuditWriter"/> on the site, drained by a real
/// <see cref="SiteAuditTelemetryActor"/> through the shared /// <see cref="SiteAuditTelemetryActor"/> through the shared
@@ -57,6 +57,8 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture) public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -146,6 +148,8 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
FastTelemetryOptions(), FastTelemetryOptions(),
NullLogger<SiteAuditTelemetryActor>.Instance))); NullLogger<SiteAuditTelemetryActor>.Instance)));
/// <summary>Verifies that one execution performing an API call and a database write produces central rows that all share one non-null execution id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId() public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId()
{ {
@@ -24,7 +24,7 @@ using System.Text.Encodings.Web;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Audit Log #23 — M4 Bundle E (Task E3) end-to-end audit trail for the /// End-to-end audit trail for the
/// inbound API surface. Wires the production /// inbound API surface. Wires the production
/// <see cref="AuditWriteMiddleware"/> into a Microsoft.AspNetCore.TestHost /// <see cref="AuditWriteMiddleware"/> into a Microsoft.AspNetCore.TestHost
/// pipeline that mirrors the production /// pipeline that mirrors the production
@@ -53,6 +53,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public InboundApiAuditTests(MsSqlMigrationFixture fixture) public InboundApiAuditTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -144,12 +146,17 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
/// </summary> /// </summary>
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions> private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{ {
/// <summary>Initializes the handler with the standard authentication-handler dependencies.</summary>
/// <param name="options">The authentication scheme options monitor.</param>
/// <param name="logger">The logger factory.</param>
/// <param name="encoder">The URL encoder.</param>
public AlwaysAuthenticatedHandler( public AlwaysAuthenticatedHandler(
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options, Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
Microsoft.Extensions.Logging.ILoggerFactory logger, Microsoft.Extensions.Logging.ILoggerFactory logger,
UrlEncoder encoder) UrlEncoder encoder)
: base(options, logger, encoder) { } : base(options, logger, encoder) { }
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync() protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
var identity = new ClaimsIdentity( var identity = new ClaimsIdentity(
@@ -200,6 +207,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
return Assert.Single(finalRows); return Assert.Single(finalRows);
} }
/// <summary>Verifies that a post with a valid actor emits a delivered inbound-request audit row with HTTP status 200 and the actor populated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated() public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated()
{ {
@@ -232,6 +241,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
Assert.NotNull(evt.AsRow().IngestedAtUtc); Assert.NotNull(evt.AsRow().IngestedAtUtc);
} }
/// <summary>Verifies that a post without authentication emits a failed inbound-auth-failure audit row with HTTP status 401 and no actor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task PostToApi_Without_Auth_Emits_InboundAuthFailure_StatusFailed_HttpStatus401_ActorNull() public async Task PostToApi_Without_Auth_Emits_InboundAuthFailure_StatusFailed_HttpStatus401_ActorNull()
{ {
@@ -260,12 +271,14 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(AuditStatus.Failed, evt.AsRow().Status); Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
Assert.Equal(401, evt.AsRow().HttpStatus); Assert.Equal(401, evt.AsRow().HttpStatus);
// Never echo back an unauthenticated principal — middleware suppresses // Never echo back an unauthenticated principal — middleware suppresses
// the framework user resolution on 401/403 paths. C3 (Task 2.5): the // the framework user resolution on 401/403 paths. The
// canonical Actor is a non-null string (empty when absent); the row view // canonical Actor is a non-null string (empty when absent); the row view
// maps empty → null, preserving the "no principal" assertion. // maps empty → null, preserving the "no principal" assertion.
Assert.Null(evt.AsRow().Actor); Assert.Null(evt.AsRow().Actor);
} }
/// <summary>Verifies that a post whose endpoint returns 500 emits a failed inbound-request audit row with HTTP status 500.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500() public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500()
{ {
@@ -20,12 +20,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Production wiring keeps the wire push deferred to M6 — the site SQLite hot /// Production wiring keeps the wire push deferred — the site SQLite hot
/// path is the source of truth and a future M6 drain will push the rows /// path is the source of truth and a future drain will push the rows
/// through the gRPC client. For end-to-end testing today we need a way to /// through the gRPC client. For end-to-end testing today we need a way to
/// exercise the central dual-write transaction immediately, so this /// exercise the central dual-write transaction immediately, so this
/// dispatcher synthesises the wire packet inline and round-trips it through /// dispatcher synthesises the wire packet inline and round-trips it through
/// the stub client. The shape mirrors what the M6 drain will eventually emit. /// the stub client. The shape mirrors what the future drain will eventually emit.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Best-effort:</b> both the inner forwarder call and the wire push are /// <b>Best-effort:</b> both the inner forwarder call and the wire push are
@@ -40,6 +40,9 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
private readonly ICachedCallTelemetryForwarder _inner; private readonly ICachedCallTelemetryForwarder _inner;
private readonly ISiteStreamAuditClient _wireClient; private readonly ISiteStreamAuditClient _wireClient;
/// <summary>Initializes the dispatcher with the inner forwarder and the wire client.</summary>
/// <param name="inner">The forwarder that handles site-local telemetry.</param>
/// <param name="wireClient">The client used to push audit rows over the wire.</param>
public CombinedTelemetryDispatcher( public CombinedTelemetryDispatcher(
ICachedCallTelemetryForwarder inner, ICachedCallTelemetryForwarder inner,
ISiteStreamAuditClient wireClient) ISiteStreamAuditClient wireClient)
@@ -48,7 +51,8 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
_wireClient = wireClient ?? throw new ArgumentNullException(nameof(wireClient)); _wireClient = wireClient ?? throw new ArgumentNullException(nameof(wireClient));
} }
/// <inheritdoc/> // Forwards the telemetry through the inner forwarder and then pushes its audit rows to the wire client.
/// <inheritdoc />
public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(telemetry); ArgumentNullException.ThrowIfNull(telemetry);
@@ -67,7 +71,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
} }
// Project the same packet onto the wire and push it through the stub // Project the same packet onto the wire and push it through the stub
// client. This is the bit a future M6 drain will subsume — until // client. This is the bit a future drain will subsume — until
// then the test wraps the two halves into one observer-driven step. // then the test wraps the two halves into one observer-driven step.
try try
{ {
@@ -79,7 +83,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
{ {
// Swallow — the audit row is still in SQLite for a future drain; // Swallow — the audit row is still in SQLite for a future drain;
// the central row will materialise the next time the wire path // the central row will materialise the next time the wire path
// is exercised (or via the M6 reconciliation pull). // is exercised (or via the reconciliation pull).
} }
} }
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
/// <summary> /// <summary>
/// Shared end-to-end harness for the M3 cached-call combined telemetry tests /// Shared end-to-end harness for the cached-call combined telemetry tests
/// (G2/G3/G4). Composes the full pipeline: /// (G2/G3/G4). Composes the full pipeline:
/// <list type="bullet"> /// <list type="bullet">
/// <item><description>Site-local SQLite <see cref="SqliteAuditWriter"/> (in-memory) + /// <item><description>Site-local SQLite <see cref="SqliteAuditWriter"/> (in-memory) +
@@ -48,15 +48,25 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
/// </remarks> /// </remarks>
public sealed class CombinedTelemetryHarness : IAsyncDisposable public sealed class CombinedTelemetryHarness : IAsyncDisposable
{ {
/// <summary>The site SQLite audit writer under test.</summary>
public SqliteAuditWriter SqliteWriter { get; } public SqliteAuditWriter SqliteWriter { get; }
/// <summary>The in-memory ring buffer fallback for audit events.</summary>
public RingBufferFallback Ring { get; } public RingBufferFallback Ring { get; }
/// <summary>The fallback audit writer that wraps the SQLite writer and the ring buffer.</summary>
public FallbackAuditWriter FallbackWriter { get; } public FallbackAuditWriter FallbackWriter { get; }
/// <summary>The site-local operation tracking store.</summary>
public OperationTrackingStore TrackingStore { get; } public OperationTrackingStore TrackingStore { get; }
/// <summary>The inner cached-call telemetry forwarder.</summary>
public CachedCallTelemetryForwarder InnerForwarder { get; } public CachedCallTelemetryForwarder InnerForwarder { get; }
/// <summary>The combined telemetry dispatcher that sends audit and operational state together.</summary>
public CombinedTelemetryDispatcher Dispatcher { get; } public CombinedTelemetryDispatcher Dispatcher { get; }
/// <summary>The cached-call lifecycle bridge wired to the forwarder.</summary>
public CachedCallLifecycleBridge Bridge { get; } public CachedCallLifecycleBridge Bridge { get; }
/// <summary>The stub site-stream audit client that delivers packets directly to the ingest actor.</summary>
public DirectActorSiteStreamAuditClient StubClient { get; } public DirectActorSiteStreamAuditClient StubClient { get; }
/// <summary>The central audit ingest actor.</summary>
public IActorRef IngestActor { get; } public IActorRef IngestActor { get; }
/// <summary>The service provider backing the central repositories.</summary>
public IServiceProvider ServiceProvider { get; } public IServiceProvider ServiceProvider { get; }
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
@@ -64,6 +74,10 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
private readonly ServiceProvider _trackingLocalDbProvider; private readonly ServiceProvider _trackingLocalDbProvider;
private bool _disposed; private bool _disposed;
/// <summary>Builds the harness, wiring the site writer chain, dispatcher and central ingest actor against the shared fixture.</summary>
/// <param name="fixture">The MS SQL migration fixture providing the central database.</param>
/// <param name="testKit">The Akka test kit that hosts the ingest actor.</param>
/// <param name="siteCallRepoOverride">Optional factory that replaces the default site-call audit repository.</param>
public CombinedTelemetryHarness( public CombinedTelemetryHarness(
MsSqlMigrationFixture fixture, MsSqlMigrationFixture fixture,
Akka.TestKit.Xunit2.TestKit testKit, Akka.TestKit.Xunit2.TestKit testKit,
@@ -133,15 +147,23 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
/// dispatcher (the bridge's hooks fire only for S&amp;F retry-loop /// dispatcher (the bridge's hooks fire only for S&amp;F retry-loop
/// attempts; submit-row emission happens at the script call site). /// attempts; submit-row emission happens at the script call site).
/// </summary> /// </summary>
/// <param name="submit">The submit packet to forward.</param>
/// <param name="ct">A token to cancel the forward.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task EmitSubmitAsync(CachedCallTelemetry submit, CancellationToken ct = default) => public Task EmitSubmitAsync(CachedCallTelemetry submit, CancellationToken ct = default) =>
Dispatcher.ForwardAsync(submit, ct); Dispatcher.ForwardAsync(submit, ct);
/// <summary> /// <summary>
/// Convenience: route a per-attempt or terminal outcome through the bridge. /// Convenience: route a per-attempt or terminal outcome through the bridge.
/// </summary> /// </summary>
/// <param name="context">The attempt or terminal-outcome context to route.</param>
/// <param name="ct">A token to cancel the emit.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task EmitAttemptAsync(CachedCallAttemptContext context, CancellationToken ct = default) => public Task EmitAttemptAsync(CachedCallAttemptContext context, CancellationToken ct = default) =>
Bridge.OnAttemptCompletedAsync(context, ct); Bridge.OnAttemptCompletedAsync(context, ct);
/// <summary>Creates a fresh central database context for reading back persisted rows.</summary>
/// <returns>A new <see cref="ScadaBridgeDbContext"/>.</returns>
public ScadaBridgeDbContext CreateReadContext() public ScadaBridgeDbContext CreateReadContext()
{ {
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -173,6 +195,8 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
/// <summary>Releases the resources held by this test class asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_disposed) return; if (_disposed) return;
@@ -12,8 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
/// Shared component-level <see cref="ISiteStreamAuditClient"/> test double that /// Shared component-level <see cref="ISiteStreamAuditClient"/> test double that
/// short-circuits the gRPC wire and forwards each batch directly to a central /// short-circuits the gRPC wire and forwards each batch directly to a central
/// <see cref="AuditLog.Central.AuditLogIngestActor"/> via Akka <see cref="Futures.Ask"/>. /// <see cref="AuditLog.Central.AuditLogIngestActor"/> via Akka <see cref="Futures.Ask"/>.
/// Lives under <c>Integration/Infrastructure/</c> so both the M2 sync-call and /// Lives under <c>Integration/Infrastructure/</c> so both the sync-call and
/// M3 cached-call end-to-end suites can reuse it. /// cached-call end-to-end suites can reuse it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -27,8 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
/// </para> /// </para>
/// <para> /// <para>
/// <see cref="FailNextCallCount"/> arms a deterministic number of failures /// <see cref="FailNextCallCount"/> arms a deterministic number of failures
/// before the stub recovers; it applies to BOTH RPCs because the M2 sync-call /// before the stub recovers; it applies to BOTH RPCs because the sync-call
/// retry behaviour and the M3 cached-telemetry retry behaviour share a single /// retry behaviour and the cached-telemetry retry behaviour share a single
/// SiteAuditTelemetryActor drain. Tests that need to differentiate per-RPC /// SiteAuditTelemetryActor drain. Tests that need to differentiate per-RPC
/// failures should reach for a per-test wrapper rather than extending this /// failures should reach for a per-test wrapper rather than extending this
/// shared infrastructure. /// shared infrastructure.
@@ -41,6 +41,8 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
private int _callCount; private int _callCount;
private int _cachedTelemetryCallCount; private int _cachedTelemetryCallCount;
/// <summary>Initializes the client with the central ingest actor that receives every forwarded batch.</summary>
/// <param name="ingestActor">The central <c>AuditLogIngestActor</c> reference batches are asked of.</param>
public DirectActorSiteStreamAuditClient(IActorRef ingestActor) public DirectActorSiteStreamAuditClient(IActorRef ingestActor)
{ {
_ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor)); _ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor));
@@ -69,6 +71,7 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
/// </summary> /// </summary>
public int CachedTelemetryCallCount => Volatile.Read(ref _cachedTelemetryCallCount); public int CachedTelemetryCallCount => Volatile.Read(ref _cachedTelemetryCallCount);
/// <inheritdoc />
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct) public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
{ {
Interlocked.Increment(ref _callCount); Interlocked.Increment(ref _callCount);
@@ -106,18 +109,11 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
return ack; return ack;
} }
/// <summary> // Dual-write path: decodes each CachedTelemetryPacket into the paired (AuditEvent, SiteCall)
/// M3 dual-write path: decode each <see cref="CachedTelemetryPacket"/> into // entry via the shared AuditEventDtoMapper.FromDto and SiteCallDtoMapper.FromDto (the same
/// the paired (<see cref="AuditEvent"/>, <see cref="SiteCall"/>) entry and // canonical mappers the production SiteStreamGrpcServer uses), then Asks the central ingest
/// Ask the central ingest actor with an <see cref="IngestCachedTelemetryCommand"/>. // actor with an IngestCachedTelemetryCommand; the accepted EventIds map back into the proto ack.
/// The accepted EventIds returned by the actor's dual-write transaction map /// <inheritdoc />
/// back into the proto ack.
/// </summary>
/// <remarks>
/// Uses the shared <see cref="AuditEventDtoMapper.FromDto"/> for the audit half
/// and <see cref="SiteCallDtoMapper.FromDto"/> for the SiteCall half — the same
/// canonical mappers the production <c>SiteStreamGrpcServer</c> uses.
/// </remarks>
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct) public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
{ {
Interlocked.Increment(ref _cachedTelemetryCallCount); Interlocked.Increment(ref _cachedTelemetryCallCount);
@@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Audit Log #23 — M4 Bundle E (Task E2): end-to-end audit trail produced by /// End-to-end audit trail produced by
/// the central <see cref="NotificationOutboxActor"/> dispatcher loop. Wires /// the central <see cref="NotificationOutboxActor"/> dispatcher loop. Wires
/// the production <see cref="CentralAuditWriter"/> onto the real /// the production <see cref="CentralAuditWriter"/> onto the real
/// <see cref="AuditLogRepository"/> against the per-class /// <see cref="AuditLogRepository"/> against the per-class
@@ -51,6 +51,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
{ {
private readonly MsSqlMigrationFixture _fixture; 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 NotifyDispatcherAuditTrailTests(MsSqlMigrationFixture fixture) public NotifyDispatcherAuditTrailTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -105,13 +107,17 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
private readonly Queue<DeliveryOutcome> _outcomes; private readonly Queue<DeliveryOutcome> _outcomes;
public int CallCount; public int CallCount;
/// <summary>Initializes the adapter with the delivery outcomes it returns in order.</summary>
/// <param name="outcomes">The outcomes returned by successive delivery calls.</param>
public QueuedOutcomeAdapter(params DeliveryOutcome[] outcomes) public QueuedOutcomeAdapter(params DeliveryOutcome[] outcomes)
{ {
_outcomes = new Queue<DeliveryOutcome>(outcomes); _outcomes = new Queue<DeliveryOutcome>(outcomes);
} }
/// <inheritdoc />
public NotificationType Type => NotificationType.Email; public NotificationType Type => NotificationType.Email;
/// <inheritdoc />
public Task<DeliveryOutcome> DeliverAsync( public Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default) Notification notification, CancellationToken cancellationToken = default)
{ {
@@ -133,7 +139,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
/// pair rather than the conservative fallback. A tiny positive RetryDelay /// pair rather than the conservative fallback. A tiny positive RetryDelay
/// means a transient outcome's <c>NextAttemptAt</c> is immediately due — /// means a transient outcome's <c>NextAttemptAt</c> is immediately due —
/// useful so the SECOND DispatchTick re-claims the row without waiting. /// useful so the SECOND DispatchTick re-claims the row without waiting.
/// NO-002: the dispatcher now clamps a non-positive RetryDelay to the /// The dispatcher now clamps a non-positive RetryDelay to the
/// 1-minute fallback to avoid burn-looping on transient failures, so this /// 1-minute fallback to avoid burn-looping on transient failures, so this
/// must be a strictly positive value (1 ms is fine for tests). /// must be a strictly positive value (1 ms is fine for tests).
/// </summary> /// </summary>
@@ -242,6 +248,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
PurgeInterval = TimeSpan.FromDays(1), PurgeInterval = TimeSpan.FromDays(1),
}; };
/// <summary>Verifies that a transient failure followed by a success emits two attempt rows and a terminal delivered row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task NotifyDispatcher_FailThenSuccess_Emits_TwoAttempts_OneDelivered_Terminal() public async Task NotifyDispatcher_FailThenSuccess_Emits_TwoAttempts_OneDelivered_Terminal()
{ {
@@ -319,6 +327,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
StopDispatcher(actor); StopDispatcher(actor);
} }
/// <summary>Verifies that a throwing audit writer does not prevent the notification delivery from succeeding.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds() public async Task NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds()
{ {
@@ -394,8 +404,10 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
{ {
private int _attemptCount; private int _attemptCount;
/// <summary>Gets the number of write attempts made against this writer.</summary>
public int AttemptCount => Volatile.Read(ref _attemptCount); public int AttemptCount => Volatile.Read(ref _attemptCount);
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{ {
Interlocked.Increment(ref _attemptCount); Interlocked.Increment(ref _attemptCount);
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Bundle F (#23 M6-T10) end-to-end test for the central-outage + reconciliation /// End-to-end test for the central-outage + reconciliation
/// recovery loop. Wires the real site SQLite hot-path /// recovery loop. Wires the real site SQLite hot-path
/// (<see cref="SqliteAuditWriter"/>) and the central <see cref="SiteAuditReconciliationActor"/> /// (<see cref="SqliteAuditWriter"/>) and the central <see cref="SiteAuditReconciliationActor"/>
/// with an <see cref="AuditLogIngestActor"/> backed by the real /// with an <see cref="AuditLogIngestActor"/> backed by the real
@@ -39,7 +39,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// the real repository. /// the real repository.
/// </para> /// </para>
/// <para> /// <para>
/// The <see cref="CombinedTelemetryHarness"/> from M3 is push-only — it has no /// The <see cref="CombinedTelemetryHarness"/> is push-only — it has no
/// reconciliation puller — so we build the smaller stub inline rather than /// reconciliation puller — so we build the smaller stub inline rather than
/// retrofitting the shared harness with a code path it doesn't otherwise /// retrofitting the shared harness with a code path it doesn't otherwise
/// need. /// need.
@@ -49,6 +49,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public OutageReconciliationTests(MsSqlMigrationFixture fixture) public OutageReconciliationTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -77,19 +79,23 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
private sealed class QueueBackedPullClient : IPullAuditEventsClient private sealed class QueueBackedPullClient : IPullAuditEventsClient
{ {
private readonly ISiteAuditQueue _siteQueue; private readonly ISiteAuditQueue _siteQueue;
/// <summary>Gets the number of times <c>PullAsync</c> has been invoked.</summary>
public int CallCount { get; private set; } public int CallCount { get; private set; }
/// <summary>Initializes the client over the given site audit queue.</summary>
/// <param name="siteQueue">The site audit queue to pull from.</param>
public QueueBackedPullClient(ISiteAuditQueue siteQueue) public QueueBackedPullClient(ISiteAuditQueue siteQueue)
{ {
_siteQueue = siteQueue ?? throw new ArgumentNullException(nameof(siteQueue)); _siteQueue = siteQueue ?? throw new ArgumentNullException(nameof(siteQueue));
} }
/// <inheritdoc />
public async Task<PullAuditEventsResponse> PullAsync( public async Task<PullAuditEventsResponse> PullAsync(
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
{ {
CallCount++; CallCount++;
// Mirrors SiteStreamGrpcServer.PullAuditEvents exactly (WP2.3): the // Mirrors SiteStreamGrpcServer.PullAuditEvents exactly: the
// INCOMING cursor is central's receipt, so everything at or before it // INCOMING cursor is central's receipt, so everything at or before it
// is retired FIRST; the rows this call serves are NOT retired, because // is retired FIRST; the rows this call serves are NOT retired, because
// nothing yet proves central consumed them. A fault between here and // nothing yet proves central consumed them. A fault between here and
@@ -119,7 +125,10 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
private sealed class StaticEnumerator : ISiteEnumerator private sealed class StaticEnumerator : ISiteEnumerator
{ {
private readonly IReadOnlyList<SiteEntry> _sites; private readonly IReadOnlyList<SiteEntry> _sites;
/// <summary>Initializes the enumerator with a fixed set of sites.</summary>
/// <param name="sites">The fixed sites to enumerate.</param>
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites; public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
/// <inheritdoc />
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) => public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites); Task.FromResult(_sites);
} }
@@ -208,6 +217,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
// 1. CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates // 1. CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that 200 events buffered during a central outage are caught up by reconciliation with no duplicates.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates() public async Task CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates()
{ {
@@ -297,8 +308,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
interval: TimeSpan.FromMilliseconds(100)); interval: TimeSpan.FromMilliseconds(100));
// Both actors have done everything the test needs; stop them HERE, before the // Both actors have done everything the test needs; stop them HERE, before the
// read-back, so nothing is still writing to the shared fixture DB while step 5 // read-back, so nothing is still writing to the shared fixture DB while the read-back
// counts rows and so a step-5 failure cannot leak them (register row 95). // counts rows and so a read-back failure cannot leak them (register row 95).
StopActor(reconciliationActor); StopActor(reconciliationActor);
StopActor(ingest); StopActor(ingest);
@@ -321,6 +332,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
// 2. ReconciliationPull_Idempotent_Across_Two_Cycles // 2. ReconciliationPull_Idempotent_Across_Two_Cycles
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that repeated reconciliation pull cycles are idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task ReconciliationPull_Idempotent_Across_Two_Cycles() public async Task ReconciliationPull_Idempotent_Across_Two_Cycles()
{ {
@@ -40,7 +40,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Audit Log #23 — <b>ParentExecutionId cross-execution correlation</b> headline /// <b>ParentExecutionId cross-execution correlation</b> headline
/// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an /// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an
/// inbound HTTP request runs an inbound method script that calls /// inbound HTTP request runs an inbound method script that calls
/// <c>Route.Call</c> into a site instance; the routed site script does a sync /// <c>Route.Call</c> into a site instance; the routed site script does a sync
@@ -101,6 +101,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public ParentExecutionIdCorrelationTests(MsSqlMigrationFixture fixture) public ParentExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -126,6 +128,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
return new ScadaBridgeDbContext(options); return new ScadaBridgeDbContext(options);
} }
/// <summary>Verifies that every audit row of a routed site run carries the inbound request's execution id as its parent execution id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId() public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId()
{ {
@@ -507,7 +511,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
async () => async () =>
{ {
var pending = await sqliteWriter.ReadPendingAsync(256); var pending = await sqliteWriter.ReadPendingAsync(256);
// AuditLog-001: ReadPendingAsync now excludes the cached-lifecycle // ReadPendingAsync now excludes the cached-lifecycle
// kinds (they ride the combined-telemetry drain), so we union // kinds (they ride the combined-telemetry drain), so we union
// them in via the dedicated read surface to keep the durability // them in via the dedicated read surface to keep the durability
// assertion covering EVERY expected Kind. // assertion covering EVERY expected Kind.
@@ -534,8 +538,10 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
/// </summary> /// </summary>
private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter
{ {
/// <inheritdoc />
public NotificationType Type => NotificationType.Email; public NotificationType Type => NotificationType.Email;
/// <inheritdoc />
public Task<DeliveryOutcome> DeliverAsync( public Task<DeliveryOutcome> DeliverAsync(
ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification notification, ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification notification,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -568,6 +574,12 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
/// </summary> /// </summary>
public string? NotificationId { get; private set; } public string? NotificationId { get; private set; }
/// <summary>Initializes the router with the collaborators the routed script run needs.</summary>
/// <param name="siteId">The site identifier stamped on routed runs.</param>
/// <param name="externalClient">The external system client used by the routed script.</param>
/// <param name="auditWriter">The audit writer the routed script emits rows through.</param>
/// <param name="cachedForwarder">The cached-call telemetry forwarder.</param>
/// <param name="storeAndForward">The store-and-forward service that buffers the notification.</param>
public BridgingInstanceRouter( public BridgingInstanceRouter(
string siteId, string siteId,
IExternalSystemClient externalClient, IExternalSystemClient externalClient,
@@ -582,6 +594,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
} }
// Runs a routed site script body locally, with the inbound execution id as its parent execution id.
/// <inheritdoc />
public async Task<RouteToCallResponse> RouteToCallAsync( public async Task<RouteToCallResponse> RouteToCallAsync(
string siteId, RouteToCallRequest request, CancellationToken cancellationToken) string siteId, RouteToCallRequest request, CancellationToken cancellationToken)
{ {
@@ -626,16 +640,19 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
request.CorrelationId, true, "routed-ok", null, DateTimeOffset.UtcNow); request.CorrelationId, true, "routed-ok", null, DateTimeOffset.UtcNow);
} }
/// <inheritdoc />
public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync( public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken) string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync( public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException(); => throw new NotSupportedException();
// WaitForAttribute is not part of this fixture's routed-Call audit scenario; // WaitForAttribute is not part of this fixture's routed-Call audit scenario;
// mirror the other non-Call methods (unexercised here). // mirror the other non-Call methods (unexercised here).
/// <inheritdoc />
public Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync( public Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken) string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException(); => throw new NotSupportedException();
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Bundle F (#23 M6-T12) end-to-end tests for the /// End-to-end tests for the
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service running /// <see cref="AuditLogPartitionMaintenanceService"/> hosted service running
/// the real EF/MSSQL <see cref="AuditLogPartitionMaintenance"/> against the /// the real EF/MSSQL <see cref="AuditLogPartitionMaintenance"/> against the
/// per-class <see cref="MsSqlMigrationFixture"/>. The migration seeds /// per-class <see cref="MsSqlMigrationFixture"/>. The migration seeds
@@ -30,6 +30,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public PartitionMaintenanceTests(MsSqlMigrationFixture fixture) public PartitionMaintenanceTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -116,6 +118,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
// 1. EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered // 1. EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that the default lookahead adds no new partition boundaries when future partitions are already covered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered() public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered()
{ {
@@ -162,6 +166,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
// 2. EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries // 2. EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that a lookahead larger than the covered range splits in new partition boundaries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries() public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries()
{ {
@@ -218,6 +224,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
// 3. EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns // 3. EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies that running partition maintenance twice adds boundaries only once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns() public async Task EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns()
{ {
@@ -19,7 +19,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Bundle F (#23 M6-T11) end-to-end test for the daily partition-switch /// End-to-end test for the daily partition-switch
/// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct /// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct
/// INSERTs that bypass the standard repository ingest path (so the seed /// INSERTs that bypass the standard repository ingest path (so the seed
/// timestamps are explicit), drives <see cref="AuditLogPurgeActor"/> against /// timestamps are explicit), drives <see cref="AuditLogPurgeActor"/> against
@@ -46,6 +46,8 @@ public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes a new instance of the <see cref="PartitionPurgeTests"/> class.</summary>
/// <param name="fixture">The fixture used by the tests.</param>
public PartitionPurgeTests(MsSqlMigrationFixture fixture) public PartitionPurgeTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -71,11 +73,11 @@ public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
string kind = "ApiCall") string kind = "ApiCall")
{ {
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
// C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson; // dbo.AuditLog is now the 10 canonical columns + DetailsJson;
// the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in // the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in
// DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive. // DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive.
// Action = "{channel}.{kind}", Category = channel name, Outcome = Success. // Action = "{channel}.{kind}", Category = channel name, Outcome = Success.
// The channel/kind are parameterized so the M5.5 per-channel purge test can // The channel/kind are parameterized so the per-channel purge test can
// seed multiple channels into the same partition. // seed multiple channels into the same partition.
cmd.CommandText = @" cmd.CommandText = @"
INSERT INTO dbo.AuditLog INSERT INTO dbo.AuditLog
@@ -111,7 +113,7 @@ VALUES
/// <summary> /// <summary>
/// Asserts the post-purge index state: the partition-ALIGNED clustered /// Asserts the post-purge index state: the partition-ALIGNED clustered
/// <c>PK_AuditLog</c> is intact and the non-aligned <c>UX_AuditLog_EventId</c> /// <c>PK_AuditLog</c> is intact and the non-aligned <c>UX_AuditLog_EventId</c>
/// is absent (WP2.2 — <c>AlignAuditLogEventIdUniqueness</c>). /// is absent (<c>AlignAuditLogEventIdUniqueness</c>).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The purge used to bracket its SWITCH with a DROP/CREATE of /// The purge used to bracket its SWITCH with a DROP/CREATE of
@@ -164,7 +166,7 @@ SELECT
/// <para> /// <para>
/// Deferred-work register row 82. Every actor these tests create ticks every /// Deferred-work register row 82. Every actor these tests create ticks every
/// <c>100 ms</c> (<see cref="AuditLogPurgeOptions.IntervalOverride"/>) and switches /// <c>100 ms</c> (<see cref="AuditLogPurgeOptions.IntervalOverride"/>) and switches
/// out the Jan-2026 partition each time it finds one eligible. The tests relied on /// out the January 2026 partition each time it finds one eligible. The tests relied on
/// <see cref="TestKit"/>'s <c>Dispose</c> → <c>Shutdown</c> to stop them between /// <see cref="TestKit"/>'s <c>Dispose</c> → <c>Shutdown</c> to stop them between
/// test methods, but that shutdown is bounded by a timeout and does NOT fail when /// test methods, but that shutdown is bounded by a timeout and does NOT fail when
/// it expires — so on a loaded machine a previous test's actor was still ticking /// it expires — so on a loaded machine a previous test's actor was still ticking
@@ -172,9 +174,9 @@ SELECT
/// </para> /// </para>
/// <para> /// <para>
/// That is what made <c>PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows</c> /// That is what made <c>PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows</c>
/// flake under a parallel full-solution run: it seeds a Jan-15-2026 row and expects /// flake under a parallel full-solution run: it seeds a 2026-01-15 row and expects
/// its own per-channel DELETE to remove exactly one row, but a leaked actor could /// its own per-channel DELETE to remove exactly one row, but a leaked actor could
/// switch the whole Jan-2026 partition out first, leaving nothing to delete /// switch the whole January 2026 partition out first, leaving nothing to delete
/// (observed: <c>Assert.Equal() Failure … Expected: 1, Actual: 0</c>). Stopping the /// (observed: <c>Assert.Equal() Failure … Expected: 1, Actual: 0</c>). Stopping the
/// actor inside the test that created it removes the dependency on shutdown timing /// actor inside the test that created it removes the dependency on shutdown timing
/// altogether. /// altogether.
@@ -228,9 +230,9 @@ SELECT
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1; var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
return ( return (
Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in Jan-2026 partition → PURGED Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in January 2026 partition → PURGED
Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in Apr-2026 partition → KEPT Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in April 2026 partition → KEPT
Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in Jun-2026 partition → KEPT Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in June 2026 partition → KEPT
RetentionDays: retentionDays RetentionDays: retentionDays
); );
} }
@@ -239,6 +241,8 @@ SELECT
// 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept // 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies the case: end To End, oldest Partition, purged Via Actor, newer Kept.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept() public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept()
{ {
@@ -283,7 +287,7 @@ SELECT
var actor = CreateActor(sp, purgeOptions, auditOptions); var actor = CreateActor(sp, purgeOptions, auditOptions);
// The Jan-2026 partition boundary is the only eligible one in this fixture DB. // The January 2026 partition boundary is the only eligible one in this fixture DB.
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var matched = probe.FishForMessage<AuditLogPurgedEvent>( var matched = probe.FishForMessage<AuditLogPurgedEvent>(
isMessage: m => m.MonthBoundary == janBoundary, isMessage: m => m.MonthBoundary == janBoundary,
@@ -313,14 +317,16 @@ SELECT
// 2. EndToEnd_AlignedUniqueness_AfterPurge // 2. EndToEnd_AlignedUniqueness_AfterPurge
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies the case: end To End, aligned Uniqueness, after Purge.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_AlignedUniqueness_AfterPurge() public async Task EndToEnd_AlignedUniqueness_AfterPurge()
{ {
Skip.IfNot(_fixture.Available, _fixture.SkipReason); Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Same shape as test 1 — purge the Jan-2026 partition and then assert the // Same shape as test 1 — purge the January 2026 partition and then assert the
// index state. RetentionDays is computed dynamically so the threshold always // index state. RetentionDays is computed dynamically so the threshold always
// lands near 2026-01-20 (see SeedOccurredAt()). Since WP2.2 the switch touches // lands near 2026-01-20 (see SeedOccurredAt()). The switch touches
// no index at all: uniqueness rides the aligned clustered PK, so there is // no index at all: uniqueness rides the aligned clustered PK, so there is
// nothing to drop before the SWITCH and nothing to rebuild after it. // nothing to drop before the SWITCH and nothing to rebuild after it.
var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8); var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
@@ -370,14 +376,16 @@ SELECT
// 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge // 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary>Verifies the case: end To End, insert If Not Exists Async, still Idempotent, after Purge.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge() public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge()
{ {
Skip.IfNot(_fixture.Available, _fixture.SkipReason); Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Seed + purge the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for // Seed + purge the January 2026 row, THEN exercise InsertIfNotExistsAsync twice for
// a fresh recent EventId. The second call must be a no-op (duplicate-key collision // a fresh recent EventId. The second call must be a no-op (duplicate-key collision
// swallowed by the repository, per M2 Bundle A's race-fix) — which means the // swallowed by the repository, per the repository's race fix) — which means the
// aligned clustered PK is still enforcing uniqueness as intended. // aligned clustered PK is still enforcing uniqueness as intended.
// RetentionDays is computed dynamically so the threshold always lands near // RetentionDays is computed dynamically so the threshold always lands near
// 2026-01-20 (see SeedOccurredAt()). // 2026-01-20 (see SeedOccurredAt()).
@@ -455,11 +463,11 @@ SELECT
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3) // 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary> /// <summary>
/// M5.5 (T3): exercises <see cref="IAuditLogRepository.PurgeChannelOlderThanAsync"/> /// Exercises <see cref="IAuditLogRepository.PurgeChannelOlderThanAsync"/>
/// directly against the real repository + fixture DB. Seeds, in the SAME partition, /// directly against the real repository + fixture DB. Seeds, in the SAME partition,
/// old + recent rows for an OVERRIDDEN channel (<c>ApiOutbound</c>) and old + recent /// old + recent rows for an OVERRIDDEN channel (<c>ApiOutbound</c>) and old + recent
/// rows for an UN-overridden channel (<c>DbOutbound</c>), then runs the per-channel /// rows for an UN-overridden channel (<c>DbOutbound</c>), then runs the per-channel
@@ -474,6 +482,7 @@ SELECT
/// the append-only writer-role DENYs do not bind (the role granularity is exercised /// the append-only writer-role DENYs do not bind (the role granularity is exercised
/// in the repository/migration tests). /// in the repository/migration tests).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows() public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows()
{ {
@@ -538,17 +547,18 @@ SELECT
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// 5. SwitchOut_WithExplicitCommandTimeout_Succeeds (Task 4) // 5. SwitchOut_WithExplicitCommandTimeout_Succeeds
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
/// <summary> /// <summary>
/// Task 4 (arch-review 04, S2): proves the explicit-<see cref="TimeSpan"/> maintenance-timeout /// Proves the explicit-<see cref="TimeSpan"/> maintenance-timeout
/// overload of <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> runs the real /// overload of <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> runs the real
/// staging/switch batch to completion against SQL Server — the old row is purged, the kept row /// staging/switch batch to completion against SQL Server — the old row is purged, the kept row
/// survives, and the returned sampled row-count reflects the switched partition. Exercising the /// survives, and the returned sampled row-count reflects the switched partition. Exercising the
/// timeout path end-to-end guards against a regression that only sets the timeout on the sample /// timeout path end-to-end guards against a regression that only sets the timeout on the sample
/// command and forgets the DDL batch (or vice versa). /// command and forgets the DDL batch (or vice versa).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task SwitchOut_WithExplicitCommandTimeout_Succeeds() public async Task SwitchOut_WithExplicitCommandTimeout_Succeeds()
{ {
@@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <summary> /// <summary>
/// Bundle H — end-to-end test wiring the full Audit Log #23 M2 sync-call pipeline: /// End-to-end test wiring the full sync-call audit pipeline:
/// <see cref="FallbackAuditWriter"/> over a <see cref="SqliteAuditWriter"/> backed by /// <see cref="FallbackAuditWriter"/> over a <see cref="SqliteAuditWriter"/> backed by
/// an in-memory SQLite database; the <see cref="SiteAuditTelemetryActor"/> drains /// an in-memory SQLite database; the <see cref="SiteAuditTelemetryActor"/> drains
/// Pending rows and pushes them through a stub <see cref="ISiteStreamAuditClient"/> /// Pending rows and pushes them through a stub <see cref="ISiteStreamAuditClient"/>
@@ -30,7 +30,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
/// <remarks> /// <remarks>
/// <para> /// <para>
/// This is a <b>component-level</b> integration test, not a full Akka-cluster /// This is a <b>component-level</b> integration test, not a full Akka-cluster
/// test (per the M2 brainstorm decision). The stub gRPC client short-circuits /// test. The stub gRPC client short-circuits
/// the wire so we exercise the real telemetry actor, the real ingest actor, the /// the wire so we exercise the real telemetry actor, the real ingest actor, the
/// real SQLite writer, and the real MSSQL repository — without standing up a /// real SQLite writer, and the real MSSQL repository — without standing up a
/// Kestrel host or two-cluster topology. /// Kestrel host or two-cluster topology.
@@ -51,6 +51,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
{ {
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test with the shared MSSQL migration fixture.</summary>
/// <param name="fixture">The MSSQL fixture that supplies the central database.</param>
public SyncCallEmissionEndToEndTests(MsSqlMigrationFixture fixture) public SyncCallEmissionEndToEndTests(MsSqlMigrationFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
@@ -129,6 +131,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
FastTelemetryOptions(), FastTelemetryOptions(),
NullLogger<SiteAuditTelemetryActor>.Instance))); NullLogger<SiteAuditTelemetryActor>.Instance)));
/// <summary>Verifies that one written audit event reaches the central AuditLog within a reasonable time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_OneWrittenEvent_Reaches_Central_AuditLog_Within_Reasonable_Time() public async Task EndToEnd_OneWrittenEvent_Reaches_Central_AuditLog_Within_Reasonable_Time()
{ {
@@ -174,6 +178,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
}, TimeSpan.FromSeconds(15)); }, TimeSpan.FromSeconds(15));
} }
/// <summary>Verifies that a gRPC stub error leaves the row pending and the next tick delivers it successfully.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds() public async Task EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds()
{ {
@@ -246,6 +252,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
}, TimeSpan.FromSeconds(15)); }, TimeSpan.FromSeconds(15));
} }
/// <summary>Verifies that submitting the same event twice results in only one central row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact] [SkippableFact]
public async Task EndToEnd_DuplicateSubmit_OnlyOneCentralRow() public async Task EndToEnd_DuplicateSubmit_OnlyOneCentralRow()
{ {
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Kpi; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Kpi;
/// <summary> /// <summary>
/// M6 "KPI History &amp; Trends" (K8) coverage for <see cref="AuditLogKpiSampleSource"/> — /// "KPI History &amp; Trends" coverage for <see cref="AuditLogKpiSampleSource"/> —
/// the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi.IKpiSampleSource"/> the /// the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi.IKpiSampleSource"/> the
/// central recorder enumerates to snapshot Audit Log KPIs. Asserts the source maps the /// central recorder enumerates to snapshot Audit Log KPIs. Asserts the source maps the
/// repository's <see cref="AuditLogKpiSnapshot"/> onto exactly three Global-scope samples /// repository's <see cref="AuditLogKpiSnapshot"/> onto exactly three Global-scope samples
@@ -19,6 +19,7 @@ public class AuditLogKpiSampleSourceTests
{ {
private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 30, 0, DateTimeKind.Utc); private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 30, 0, DateTimeKind.Utc);
/// <summary>Verifies that the sample source reports the AuditLog source name.</summary>
[Fact] [Fact]
public void Source_is_AuditLog() public void Source_is_AuditLog()
{ {
@@ -28,6 +29,8 @@ public class AuditLogKpiSampleSourceTests
Assert.Equal(KpiSources.AuditLog, sut.Source); Assert.Equal(KpiSources.AuditLog, sut.Source);
} }
/// <summary>Verifies that collecting emits three global samples mapped from the health snapshot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CollectAsync_emits_three_global_samples_mapped_from_snapshot() public async Task CollectAsync_emits_three_global_samples_mapped_from_snapshot()
{ {
@@ -64,6 +67,8 @@ public class AuditLogKpiSampleSourceTests
AssertMetric(samples, "backlogTotal", 305); AssertMetric(samples, "backlogTotal", 305);
} }
/// <summary>Verifies that the sample window is anchored on the snapshot's capture time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CollectAsync_anchors_the_window_on_capturedAtUtc() public async Task CollectAsync_anchors_the_window_on_capturedAtUtc()
{ {
@@ -83,6 +88,8 @@ public class AuditLogKpiSampleSourceTests
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that collecting returns no samples when the snapshot is null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CollectAsync_returns_empty_when_snapshot_is_null() public async Task CollectAsync_returns_empty_when_snapshot_is_null()
{ {
@@ -98,6 +105,8 @@ public class AuditLogKpiSampleSourceTests
Assert.Empty(samples); Assert.Empty(samples);
} }
/// <summary>Verifies that the backlog provider's value is used when one is registered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CollectAsync_uses_backlog_provider_when_present() public async Task CollectAsync_uses_backlog_provider_when_present()
{ {
@@ -127,11 +136,13 @@ public class AuditLogKpiSampleSourceTests
AssertMetric(samples, "backlogTotal", 42); AssertMetric(samples, "backlogTotal", 42);
} }
/// <summary>Verifies that the snapshot's own backlog total is used when no backlog provider is registered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CollectAsync_falls_back_to_snapshot_backlog_when_provider_absent() public async Task CollectAsync_falls_back_to_snapshot_backlog_when_provider_absent()
{ {
// No provider (sites / unit tests): the sample source degrades to the // No provider (sites / unit tests): the sample source degrades to the
// snapshot's own BacklogTotal — unchanged pre-Task-10 behavior. // snapshot's own BacklogTotal — unchanged behavior from before the provider existed.
var snapshot = new AuditLogKpiSnapshot( var snapshot = new AuditLogKpiSnapshot(
TotalEventsLastHour: 10, TotalEventsLastHour: 10,
ErrorEventsLastHour: 1, ErrorEventsLastHour: 1,
@@ -10,8 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
/// <summary> /// <summary>
/// Guards the fail-closed contract for unavailable redactor patterns /// Guards the fail-closed contract for unavailable redactor patterns.
/// (Gitea #35).
/// ///
/// <para> /// <para>
/// <b>The defect.</b> <see cref="AuditRegexCache"/> rejects a pattern that is /// <b>The defect.</b> <see cref="AuditRegexCache"/> rejects a pattern that is
@@ -118,6 +117,7 @@ public class AuditRedactorFailClosedTests
Assert.Equal(0, counter.Count); Assert.Equal(0, counter.Count);
} }
/// <summary>Verifies that an unavailable global body redactor suppresses the payload rather than emitting it raw.</summary>
[Fact] [Fact]
public void UnavailableGlobalBodyRedactor_SuppressesPayload_RatherThanEmittingItRaw() public void UnavailableGlobalBodyRedactor_SuppressesPayload_RatherThanEmittingItRaw()
{ {
@@ -152,6 +152,7 @@ public class AuditRedactorFailClosedTests
Assert.Equal(1, counter.Count); Assert.Equal(1, counter.Count);
} }
/// <summary>Verifies that an unavailable per-target body redactor suppresses the payload.</summary>
[Fact] [Fact]
public void UnavailablePerTargetBodyRedactor_SuppressesPayload() public void UnavailablePerTargetBodyRedactor_SuppressesPayload()
{ {
@@ -267,16 +268,27 @@ public class AuditRedactorFailClosedTests
private sealed class CountingCounter : IAuditRedactionFailureCounter private sealed class CountingCounter : IAuditRedactionFailureCounter
{ {
private int _count; private int _count;
/// <summary>Gets the number of redaction failures counted so far.</summary>
public int Count => Volatile.Read(ref _count); public int Count => Volatile.Read(ref _count);
/// <inheritdoc />
public void Increment() => Interlocked.Increment(ref _count); public void Increment() => Interlocked.Increment(ref _count);
} }
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions> private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{ {
private readonly AuditLogOptions _value; private readonly AuditLogOptions _value;
/// <summary>Initializes the monitor with a fixed options value.</summary>
/// <param name="value">The value.</param>
public StaticMonitor(AuditLogOptions value) => _value = value; public StaticMonitor(AuditLogOptions value) => _value = value;
/// <summary>Gets the current options value.</summary>
public AuditLogOptions CurrentValue => _value; public AuditLogOptions CurrentValue => _value;
/// <summary>Returns the current options value regardless of the name.</summary>
/// <param name="name">The options name; ignored.</param>
/// <returns>The current options value.</returns>
public AuditLogOptions Get(string? name) => _value; public AuditLogOptions Get(string? name) => _value;
/// <summary>Registers a listener to be notified when the options change.</summary>
/// <param name="listener">The change callback.</param>
/// <returns>A disposable that removes the listener, or null when none is tracked.</returns>
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null; public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
} }
@@ -286,10 +298,14 @@ public class AuditRedactorFailClosedTests
private readonly List<Action<AuditLogOptions, string?>> _listeners = new(); private readonly List<Action<AuditLogOptions, string?>> _listeners = new();
private int _reads; private int _reads;
/// <summary>Initializes the monitor with an initial options value.</summary>
/// <param name="value">The value.</param>
public MutableMonitor(AuditLogOptions value) => _value = value; public MutableMonitor(AuditLogOptions value) => _value = value;
/// <summary>Gets the number of times <c>CurrentValue</c> has been read.</summary>
public int CurrentValueReads => Volatile.Read(ref _reads); public int CurrentValueReads => Volatile.Read(ref _reads);
/// <summary>Gets the current options value.</summary>
public AuditLogOptions CurrentValue public AuditLogOptions CurrentValue
{ {
get get
@@ -299,8 +315,14 @@ public class AuditRedactorFailClosedTests
} }
} }
/// <summary>Gets the current options value.</summary>
/// <param name="name">The options name (ignored).</param>
/// <returns>The current options value.</returns>
public AuditLogOptions Get(string? name) => CurrentValue; public AuditLogOptions Get(string? name) => CurrentValue;
/// <summary>Registers a listener to be notified when the options change.</summary>
/// <param name="listener">The change callback.</param>
/// <returns>A disposable that removes the listener, or null when none is tracked.</returns>
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) public IDisposable? OnChange(Action<AuditLogOptions, string?> listener)
{ {
lock (_listeners) lock (_listeners)
@@ -310,6 +332,8 @@ public class AuditRedactorFailClosedTests
return new Noop(); return new Noop();
} }
/// <summary>Replaces the current options value and notifies every registered listener.</summary>
/// <param name="value">The value.</param>
public void Set(AuditLogOptions value) public void Set(AuditLogOptions value)
{ {
_value = value; _value = value;
@@ -326,6 +350,7 @@ public class AuditRedactorFailClosedTests
private sealed class Noop : IDisposable private sealed class Noop : IDisposable
{ {
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose() { } public void Dispose() { }
} }
} }
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
/// <summary> /// <summary>
/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for /// Tests for
/// <see cref="SafeDefaultAuditRedactor"/> — the minimal always-safe /// <see cref="SafeDefaultAuditRedactor"/> — the minimal always-safe
/// <see cref="ZB.MOM.WW.Audit.IAuditRedactor"/> fallback. /// <see cref="ZB.MOM.WW.Audit.IAuditRedactor"/> fallback.
/// Header-only scrub of the always-sensitive default headers inside /// Header-only scrub of the always-sensitive default headers inside
@@ -35,6 +35,7 @@ public class SafeDefaultAuditRedactorTests
private static AuditDetails Details(AuditEvent evt) => private static AuditDetails Details(AuditEvent evt) =>
AuditDetailsCodec.Deserialize(evt.DetailsJson); AuditDetailsCodec.Deserialize(evt.DetailsJson);
/// <summary>Verifies that the default sensitive headers are redacted in the request summary.</summary>
[Fact] [Fact]
public void Redacts_DefaultSensitiveHeaders_InRequestSummary() public void Redacts_DefaultSensitiveHeaders_InRequestSummary()
{ {
@@ -48,6 +49,7 @@ public class SafeDefaultAuditRedactorTests
Assert.Contains("Content-Type: application/json", d.RequestSummary!); Assert.Contains("Content-Type: application/json", d.RequestSummary!);
} }
/// <summary>Verifies that the default sensitive headers are redacted in the response summary.</summary>
[Fact] [Fact]
public void Redacts_DefaultSensitiveHeaders_InResponseSummary() public void Redacts_DefaultSensitiveHeaders_InResponseSummary()
{ {
@@ -61,6 +63,7 @@ public class SafeDefaultAuditRedactorTests
Assert.Contains("X-Other: ok", d.ResponseSummary!); Assert.Contains("X-Other: ok", d.ResponseSummary!);
} }
/// <summary>Verifies that header names are matched case-insensitively when redacting.</summary>
[Fact] [Fact]
public void CaseInsensitive_HeaderName_Redacted() public void CaseInsensitive_HeaderName_Redacted()
{ {
@@ -72,6 +75,7 @@ public class SafeDefaultAuditRedactorTests
Assert.DoesNotContain("x-y-z", Details(result).RequestSummary!); Assert.DoesNotContain("x-y-z", Details(result).RequestSummary!);
} }
/// <summary>Verifies that a non-sensitive header is preserved unchanged.</summary>
[Fact] [Fact]
public void NonSensitiveHeader_Preserved() public void NonSensitiveHeader_Preserved()
{ {
@@ -84,6 +88,7 @@ public class SafeDefaultAuditRedactorTests
Assert.Contains("Accept: application/json", d.RequestSummary!); Assert.Contains("Accept: application/json", d.RequestSummary!);
} }
/// <summary>Verifies that an event with null details takes the fast path and returns the same instance.</summary>
[Fact] [Fact]
public void NullDetails_FastPath_ReturnsSameInstance() public void NullDetails_FastPath_ReturnsSameInstance()
{ {
@@ -102,6 +107,7 @@ public class SafeDefaultAuditRedactorTests
Assert.Same(evt, result); Assert.Same(evt, result);
} }
/// <summary>Verifies that malformed details JSON never causes the redactor to throw.</summary>
[Fact] [Fact]
public void MalformedDetailsJson_NeverThrows() public void MalformedDetailsJson_NeverThrows()
{ {
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
/// <summary> /// <summary>
/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for /// ScadaBridge audit re-architecture stage C2 tests for
/// <see cref="ScadaBridgeAuditRedactor"/> — the canonical /// <see cref="ScadaBridgeAuditRedactor"/> — the canonical
/// <see cref="IAuditRedactor"/> implementation. Covers the header-redaction / /// <see cref="IAuditRedactor"/> implementation. Covers the header-redaction /
/// body-regex / SQL-param / safety-net / truncation pipeline operating on /// body-regex / SQL-param / safety-net / truncation pipeline operating on
@@ -20,6 +20,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
/// </summary> /// </summary>
public class ScadaBridgeAuditRedactorTests public class ScadaBridgeAuditRedactorTests
{ {
/// <summary>Verifies that every field of an error row is redacted and the error message is capped, whether the redactor is configured globally or per target.</summary>
/// <param name="perTarget">True to configure the redactor through a per-target override; false to configure it globally.</param>
[Theory] [Theory]
[InlineData(false)] [InlineData(false)]
[InlineData(true)] [InlineData(true)]
@@ -35,6 +37,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.True(Encoding.UTF8.GetByteCount(Details(result).ErrorMessage!) <= 128); Assert.True(Encoding.UTF8.GetByteCount(Details(result).ErrorMessage!) <= 128);
} }
/// <summary>Verifies that the SQL parameter policy does not persist values echoed back in an exception message.</summary>
[Fact] [Fact]
public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues() public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues()
{ {
@@ -101,6 +104,7 @@ public class ScadaBridgeAuditRedactorTests
// ---- Header redaction (ports HeaderRedactionTests) --------------------- // ---- Header redaction (ports HeaderRedactionTests) ---------------------
/// <summary>Verifies that an Authorization bearer header is redacted.</summary>
[Fact] [Fact]
public void HeaderRedaction_AuthorizationBearer_Redacted() public void HeaderRedaction_AuthorizationBearer_Redacted()
{ {
@@ -116,6 +120,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Contains("application/json", d.RequestSummary); Assert.Contains("application/json", d.RequestSummary);
} }
/// <summary>Verifies that header redaction is case-insensitive, so a lowercase authorization header is redacted.</summary>
[Fact] [Fact]
public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted() public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
{ {
@@ -127,6 +132,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Contains("\"authorization\":\"<redacted>\"", Details(result).RequestSummary!); Assert.Contains("\"authorization\":\"<redacted>\"", Details(result).RequestSummary!);
} }
/// <summary>Verifies that a custom redact list redacts a custom header name.</summary>
[Fact] [Fact]
public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName() public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
{ {
@@ -141,6 +147,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Contains("Bearer keep-me", d.RequestSummary!); Assert.Contains("Bearer keep-me", d.RequestSummary!);
} }
/// <summary>Verifies that a request summary that is not JSON passes through header redaction unchanged.</summary>
[Fact] [Fact]
public void HeaderRedaction_NonJson_RequestSummary_Unchanged() public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
{ {
@@ -153,6 +160,7 @@ public class ScadaBridgeAuditRedactorTests
// ---- Body regex redaction (ports BodyRegexRedactionTests) -------------- // ---- Body regex redaction (ports BodyRegexRedactionTests) --------------
/// <summary>Verifies that the global regex redacts a Hunter-style password value.</summary>
[Fact] [Fact]
public void GlobalRegex_HunterPassword_Redacted() public void GlobalRegex_HunterPassword_Redacted()
{ {
@@ -170,6 +178,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Contains("alice", d.RequestSummary!); Assert.Contains("alice", d.RequestSummary!);
} }
/// <summary>Verifies that the body regex is applied to the error detail and the extra field.</summary>
[Fact] [Fact]
public void BodyRegex_AppliesToErrorDetailAndExtra() public void BodyRegex_AppliesToErrorDetailAndExtra()
{ {
@@ -190,6 +199,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Contains("<redacted>", d.Extra!); Assert.Contains("<redacted>", d.Extra!);
} }
/// <summary>Verifies that a per-target regex is applied only to the matching target.</summary>
[Fact] [Fact]
public void PerTargetRegex_OnlyAppliedToMatchingTarget() public void PerTargetRegex_OnlyAppliedToMatchingTarget()
{ {
@@ -213,6 +223,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Equal(input, Details(unmatched).RequestSummary); Assert.Equal(input, Details(unmatched).RequestSummary);
} }
/// <summary>Verifies that a field is left unchanged when no regex is configured.</summary>
[Fact] [Fact]
public void NoRegexConfigured_FieldUnchanged() public void NoRegexConfigured_FieldUnchanged()
{ {
@@ -223,6 +234,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary); Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary);
} }
/// <summary>Verifies that a regex that times out turns the field into the redacted marker and increments the failure counter.</summary>
[Fact] [Fact]
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements() public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
{ {
@@ -257,6 +269,7 @@ public class ScadaBridgeAuditRedactorTests
return sb.ToString(); return sb.ToString();
} }
/// <summary>Verifies that SQL parameters are kept verbatim when no opt-in is configured.</summary>
[Fact] [Fact]
public void NoOptIn_ParamsVerbatim_Unchanged() public void NoOptIn_ParamsVerbatim_Unchanged()
{ {
@@ -272,6 +285,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Equal(input, Details(result).RequestSummary); Assert.Equal(input, Details(result).RequestSummary);
} }
/// <summary>Verifies that an opt-in regex on parameter names redacts the matching values and keeps the others.</summary>
[Fact] [Fact]
public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers() public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers()
{ {
@@ -298,6 +312,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.DoesNotContain("k-987", d.RequestSummary!); Assert.DoesNotContain("k-987", d.RequestSummary!);
} }
/// <summary>Verifies that a channel other than database-outbound is not affected by the SQL parameter policy.</summary>
[Fact] [Fact]
public void NonDbOutboundChannel_NotAffected() public void NonDbOutboundChannel_NotAffected()
{ {
@@ -320,6 +335,7 @@ public class ScadaBridgeAuditRedactorTests
// ---- Truncation + cap selection (ports TruncationTests) ---------------- // ---- Truncation + cap selection (ports TruncationTests) ----------------
/// <summary>Verifies that a 10 KB request summary on a success row is truncated to 8 KB and flagged as payload-truncated.</summary>
[Fact] [Fact]
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue() public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
{ {
@@ -334,6 +350,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.True(d.PayloadTruncated); Assert.True(d.PayloadTruncated);
} }
/// <summary>Verifies that a 10 KB request summary on a failed row is not truncated because it is under the error cap.</summary>
[Fact] [Fact]
public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap() public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap()
{ {
@@ -348,6 +365,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.False(d.PayloadTruncated); Assert.False(d.PayloadTruncated);
} }
/// <summary>Verifies that a 70 KB request summary on a failed row is truncated to 64 KB.</summary>
[Fact] [Fact]
public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB() public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB()
{ {
@@ -362,6 +380,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.True(d.PayloadTruncated); Assert.True(d.PayloadTruncated);
} }
/// <summary>Verifies that an attempted status is treated as an error and uses the error cap even though the outcome is success.</summary>
[Fact] [Fact]
public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess() public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
{ {
@@ -379,6 +398,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.False(d.PayloadTruncated); Assert.False(d.PayloadTruncated);
} }
/// <summary>Verifies that a skipped status is treated as an error and uses the error cap even though the outcome is success.</summary>
[Fact] [Fact]
public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess() public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
{ {
@@ -391,6 +411,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.False(Details(result).PayloadTruncated); Assert.False(Details(result).PayloadTruncated);
} }
/// <summary>Verifies that a submitted status is treated as a success and uses the default cap.</summary>
[Fact] [Fact]
public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap() public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap()
{ {
@@ -406,6 +427,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.True(d.PayloadTruncated); Assert.True(d.PayloadTruncated);
} }
/// <summary>Verifies that a large inbound API body uses the inbound cap rather than the default cap.</summary>
[Fact] [Fact]
public void ApiInbound_LargeBody_UsesInboundCap_NotDefault() public void ApiInbound_LargeBody_UsesInboundCap_NotDefault()
{ {
@@ -420,6 +442,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!)); Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!));
} }
/// <summary>Verifies that a failed inbound API body above the inbound maximum is truncated to that maximum.</summary>
[Fact] [Fact]
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes() public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
{ {
@@ -435,6 +458,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384); Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384);
} }
/// <summary>Verifies that multibyte UTF-8 text is truncated at a character boundary rather than in the middle of a byte sequence.</summary>
[Fact] [Fact]
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte() public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
{ {
@@ -453,6 +477,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.True(d.PayloadTruncated); Assert.True(d.PayloadTruncated);
} }
/// <summary>Verifies that a details payload that was already marked truncated stays truncated.</summary>
[Fact] [Fact]
public void ExistingDetailsPayloadTruncated_RemainsTrue() public void ExistingDetailsPayloadTruncated_RemainsTrue()
{ {
@@ -467,6 +492,7 @@ public class ScadaBridgeAuditRedactorTests
// ---- Target length cap ------------------------------------------------- // ---- Target length cap -------------------------------------------------
/// <summary>Verifies that a target over the cap is truncated at a byte boundary.</summary>
[Fact] [Fact]
public void Target_OverCap_Truncated_ToByteBoundary() public void Target_OverCap_Truncated_ToByteBoundary()
{ {
@@ -483,6 +509,7 @@ public class ScadaBridgeAuditRedactorTests
// ---- Fast-path --------------------------------------------------------- // ---- Fast-path ---------------------------------------------------------
/// <summary>Verifies that the fast path returns the same instance when the details are null and the target is short.</summary>
[Fact] [Fact]
public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance() public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance()
{ {
@@ -502,6 +529,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Same(evt, result); Assert.Same(evt, result);
} }
/// <summary>Verifies that the fast path returns the same instance when the details are empty and the target is short.</summary>
[Fact] [Fact]
public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance() public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance()
{ {
@@ -523,6 +551,7 @@ public class ScadaBridgeAuditRedactorTests
// ---- Never-throws safety net ------------------------------------------- // ---- Never-throws safety net -------------------------------------------
/// <summary>Verifies that malformed details JSON never throws and yields a safe copy.</summary>
[Fact] [Fact]
public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy() public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy()
{ {
@@ -545,6 +574,7 @@ public class ScadaBridgeAuditRedactorTests
Assert.Null(ex); Assert.Null(ex);
} }
/// <summary>Verifies that a fault thrown by the options monitor never leaks sensitive values, because the outer catch over-redacts every sensitive field.</summary>
[Fact] [Fact]
public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted() public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted()
{ {
@@ -601,7 +631,9 @@ public class ScadaBridgeAuditRedactorTests
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
{ {
private int _count; private int _count;
/// <summary>Gets the number of recorded failures.</summary>
public int Count => _count; public int Count => _count;
/// <inheritdoc />
public void Increment() => System.Threading.Interlocked.Increment(ref _count); public void Increment() => System.Threading.Interlocked.Increment(ref _count);
} }
@@ -609,9 +641,18 @@ public class ScadaBridgeAuditRedactorTests
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions> private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{ {
private readonly AuditLogOptions _value; private readonly AuditLogOptions _value;
/// <summary>Initializes a new instance of the <see cref="StaticMonitor"/> class.</summary>
/// <param name="value">The options snapshot returned on every read.</param>
public StaticMonitor(AuditLogOptions value) => _value = value; public StaticMonitor(AuditLogOptions value) => _value = value;
/// <summary>Gets the options value this monitor serves.</summary>
public AuditLogOptions CurrentValue => _value; public AuditLogOptions CurrentValue => _value;
/// <summary>Returns the options value regardless of name.</summary>
/// <param name="name">The options name (ignored).</param>
/// <returns>The options value.</returns>
public AuditLogOptions Get(string? name) => _value; public AuditLogOptions Get(string? name) => _value;
/// <summary>Ignores the listener; the options never change.</summary>
/// <param name="listener">The change listener (ignored).</param>
/// <returns>Always null.</returns>
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null; public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
} }
@@ -622,9 +663,16 @@ public class ScadaBridgeAuditRedactorTests
/// </summary> /// </summary>
private sealed class ThrowingMonitor : IOptionsMonitor<AuditLogOptions> private sealed class ThrowingMonitor : IOptionsMonitor<AuditLogOptions>
{ {
/// <summary>Gets the options value this monitor serves.</summary>
public AuditLogOptions CurrentValue => public AuditLogOptions CurrentValue =>
throw new InvalidOperationException("Simulated options fault for outer-catch test"); throw new InvalidOperationException("Simulated options fault for outer-catch test");
/// <summary>Returns the options value regardless of name.</summary>
/// <param name="name">The options name (ignored).</param>
/// <returns>The options value.</returns>
public AuditLogOptions Get(string? name) => new AuditLogOptions(); public AuditLogOptions Get(string? name) => new AuditLogOptions();
/// <summary>Ignores the listener; the options never change.</summary>
/// <param name="listener">The change listener (ignored).</param>
/// <returns>Always null.</returns>
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null; public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
} }
} }
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Bundle B (M2-T4) tests for <see cref="FallbackAuditWriter"/> — composes the /// Tests for <see cref="FallbackAuditWriter"/> — composes the
/// primary <see cref="SqliteAuditWriter"/>, the drop-oldest /// primary <see cref="SqliteAuditWriter"/>, the drop-oldest
/// <see cref="RingBufferFallback"/>, and an /// <see cref="RingBufferFallback"/>, and an
/// <see cref="IAuditWriteFailureCounter"/> health counter. /// <see cref="IAuditWriteFailureCounter"/> health counter.
@@ -28,9 +28,12 @@ public class FallbackAuditWriterTests
/// <summary>Flip-switch primary writer mock.</summary> /// <summary>Flip-switch primary writer mock.</summary>
private sealed class FlipSwitchPrimary : IAuditWriter private sealed class FlipSwitchPrimary : IAuditWriter
{ {
/// <summary>Gets or sets a value indicating whether the next write fails.</summary>
public bool FailNext { get; set; } public bool FailNext { get; set; }
/// <summary>Gets the events the primary has accepted.</summary>
public List<AuditEvent> Written { get; } = new(); public List<AuditEvent> Written { get; } = new();
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{ {
if (FailNext) if (FailNext)
@@ -42,6 +45,8 @@ public class FallbackAuditWriterTests
} }
} }
/// <summary>Verifies that when the primary writer throws, the event lands in the ring buffer and the call still returns successfully.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess() public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess()
{ {
@@ -59,6 +64,8 @@ public class FallbackAuditWriterTests
counter.Received(1).Increment(); counter.Received(1).Increment();
} }
/// <summary>Verifies that after the primary recovers, the ring buffer drains in FIFO order on the next write.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite() public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite()
{ {
@@ -93,6 +100,8 @@ public class FallbackAuditWriterTests
Assert.Equal("c", primary.Written[3].Target); Assert.Equal("c", primary.Written[3].Target);
} }
/// <summary>Verifies that the ring buffer stays empty when the primary writer always succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty() public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty()
{ {
@@ -112,6 +121,8 @@ public class FallbackAuditWriterTests
counter.DidNotReceive().Increment(); counter.DidNotReceive().Increment();
} }
/// <summary>Verifies that the failure counter is incremented once per primary failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure() public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure()
{ {
@@ -5,17 +5,18 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Bundle C (M5-T7) — the <see cref="HealthMetricsAuditRedactionFailureCounter"/> /// The <see cref="HealthMetricsAuditRedactionFailureCounter"/>
/// adapter is the production binding for /// adapter is the production binding for
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/> on /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/> on
/// site nodes; it forwards every <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> /// site nodes; it forwards every <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// redactor over-redaction event into the shared /// redactor over-redaction event into the shared
/// <see cref="ISiteHealthCollector"/> so the site health report surfaces the /// <see cref="ISiteHealthCollector"/> so the site health report surfaces the
/// count as <c>AuditRedactionFailure</c>. Mirrors the M2 Bundle G /// count as <c>AuditRedactionFailure</c>. Mirrors the
/// HealthMetricsAuditWriteFailureCounter shape one-for-one. /// HealthMetricsAuditWriteFailureCounter shape one-for-one.
/// </summary> /// </summary>
public class HealthMetricsAuditRedactionFailureCounterTests public class HealthMetricsAuditRedactionFailureCounterTests
{ {
/// <summary>Verifies the case: increment, routes, to, collector, increment Audit Redaction Failure.</summary>
[Fact] [Fact]
public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure() public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure()
{ {
@@ -27,6 +28,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests
collector.Received(1).IncrementAuditRedactionFailure(); collector.Received(1).IncrementAuditRedactionFailure();
} }
/// <summary>Verifies the case: increment, multiple, calls, route, to, collector, each, time.</summary>
[Fact] [Fact]
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time() public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
{ {
@@ -40,6 +42,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests
collector.Received(3).IncrementAuditRedactionFailure(); collector.Received(3).IncrementAuditRedactionFailure();
} }
/// <summary>Verifies the case: construction, with, null, collector, throws, argument Null Exception.</summary>
[Fact] [Fact]
public void Construction_With_Null_Collector_Throws_ArgumentNullException() public void Construction_With_Null_Collector_Throws_ArgumentNullException()
{ {
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Bundle G (M2-T11) — the <see cref="HealthMetricsAuditWriteFailureCounter"/> /// The <see cref="HealthMetricsAuditWriteFailureCounter"/>
/// adapter is the production binding for <see cref="IAuditWriteFailureCounter"/> /// adapter is the production binding for <see cref="IAuditWriteFailureCounter"/>
/// on site nodes; it forwards every FallbackAuditWriter primary failure into /// on site nodes; it forwards every FallbackAuditWriter primary failure into
/// the shared <see cref="ISiteHealthCollector"/> so the site health report /// the shared <see cref="ISiteHealthCollector"/> so the site health report
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// </summary> /// </summary>
public class HealthMetricsAuditWriteFailureCounterTests public class HealthMetricsAuditWriteFailureCounterTests
{ {
/// <summary>Verifies that a single increment is routed to the collector as a site audit write failure.</summary>
[Fact] [Fact]
public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures() public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures()
{ {
@@ -24,6 +25,7 @@ public class HealthMetricsAuditWriteFailureCounterTests
collector.Received(1).IncrementSiteAuditWriteFailures(); collector.Received(1).IncrementSiteAuditWriteFailures();
} }
/// <summary>Verifies that each of several increments is routed to the collector.</summary>
[Fact] [Fact]
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time() public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
{ {
@@ -37,6 +39,7 @@ public class HealthMetricsAuditWriteFailureCounterTests
collector.Received(3).IncrementSiteAuditWriteFailures(); collector.Received(3).IncrementSiteAuditWriteFailures();
} }
/// <summary>Verifies that constructing the counter with a null collector throws an <see cref="ArgumentNullException"/>.</summary>
[Fact] [Fact]
public void Construction_With_Null_Collector_Throws_ArgumentNullException() public void Construction_With_Null_Collector_Throws_ArgumentNullException()
{ {
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Bundle B (M2-T3) tests for <see cref="RingBufferFallback"/> — the /// Tests for <see cref="RingBufferFallback"/> — the
/// drop-oldest fallback used by <see cref="FallbackAuditWriter"/> when the /// drop-oldest fallback used by <see cref="FallbackAuditWriter"/> when the
/// primary SQLite writer is throwing. /// primary SQLite writer is throwing.
/// </summary> /// </summary>
@@ -23,6 +23,8 @@ public class RingBufferFallbackTests
target: target); target: target);
} }
/// <summary>Verifies that enqueuing 1025 events into a ring with a capacity of 1024 drops the oldest event and raises the overflow notification exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce() public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce()
{ {
@@ -51,6 +53,8 @@ public class RingBufferFallbackTests
Assert.Equal("1024", drained[^1].Target); Assert.Equal("1024", drained[^1].Target);
} }
/// <summary>Verifies that draining yields the queued events in FIFO order and then completes once the ring is empty.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty() public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty()
{ {
@@ -76,6 +80,7 @@ public class RingBufferFallbackTests
} }
} }
/// <summary>Verifies that enqueuing a single event into a ring with spare capacity succeeds and returns true.</summary>
[Fact] [Fact]
public void TryEnqueue_AllSucceeds_ReturnsTrue() public void TryEnqueue_AllSucceeds_ReturnsTrue()
{ {
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Deferred-work #21: the backlog reporter's poll cadence is configurable via /// The backlog reporter's poll cadence is configurable via
/// <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/> instead of the /// <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/> instead of the
/// old hard-coded 30 s constant. /// old hard-coded 30 s constant.
/// </summary> /// </summary>
@@ -26,6 +26,8 @@ public class SiteAuditBacklogReporterCadenceTests
explicitInterval, explicitInterval,
options); options);
/// <summary>Verifies that stopping the reporter while a probe is in flight lets the loop complete cleanly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task StopAsync_WhileProbeInFlight_LoopCompletesCleanly() public async Task StopAsync_WhileProbeInFlight_LoopCompletesCleanly()
{ {
@@ -62,10 +64,12 @@ public class SiteAuditBacklogReporterCadenceTests
throw new UnreachableException("the delay above always throws on cancellation"); throw new UnreachableException("the delay above always throws on cancellation");
} }
/// <summary>Verifies that stopping the reporter after it has been disposed does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task StopAsync_AfterDispose_DoesNotThrow() public async Task StopAsync_AfterDispose_DoesNotThrow()
{ {
// Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync // Regression (follow-up): Dispose tears down the CTS StopAsync
// cancels, and the host does not guarantee StopAsync is driven before the DI // cancels, and the host does not guarantee StopAsync is driven before the DI
// container is disposed. Cancel() on a disposed CTS throws, and letting that // container is disposed. Cancel() on a disposed CTS throws, and letting that
// escape an IHostedService aborts the host's whole shutdown sequence. // escape an IHostedService aborts the host's whole shutdown sequence.
@@ -79,6 +83,7 @@ public class SiteAuditBacklogReporterCadenceTests
reporter.Dispose(); reporter.Dispose();
} }
/// <summary>Verifies that the poll cadence comes from the options when one is configured.</summary>
[Fact] [Fact]
public void Cadence_ComesFromOptions_WhenConfigured() public void Cadence_ComesFromOptions_WhenConfigured()
{ {
@@ -89,6 +94,7 @@ public class SiteAuditBacklogReporterCadenceTests
Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval); Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval);
} }
/// <summary>Verifies that a non-positive configured interval falls back to the default cadence.</summary>
[Fact] [Fact]
public void Cadence_FallsBackToDefault_WhenOptionsNonPositive() public void Cadence_FallsBackToDefault_WhenOptionsNonPositive()
{ {
@@ -99,6 +105,7 @@ public class SiteAuditBacklogReporterCadenceTests
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval); Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
} }
/// <summary>Verifies that the default cadence is used when no options are supplied.</summary>
[Fact] [Fact]
public void Cadence_FallsBackToDefault_WhenNoOptions() public void Cadence_FallsBackToDefault_WhenNoOptions()
{ {
@@ -107,6 +114,7 @@ public class SiteAuditBacklogReporterCadenceTests
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval); Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
} }
/// <summary>Verifies that an explicitly supplied interval takes precedence over the options value.</summary>
[Fact] [Fact]
public void ExplicitInterval_WinsOverOptions() public void ExplicitInterval_WinsOverOptions()
{ {
@@ -119,6 +127,7 @@ public class SiteAuditBacklogReporterCadenceTests
// ----- Stale-Pending signal (review F5) ----- // // ----- Stale-Pending signal (review F5) ----- //
/// <summary>Verifies that a stale pending backlog is logged as a warning and that repeat warnings are then rate-limited.</summary>
[Fact] [Fact]
public void StalePendingBacklog_IsWarned_ThenRateLimited() public void StalePendingBacklog_IsWarned_ThenRateLimited()
{ {
@@ -143,6 +152,7 @@ public class SiteAuditBacklogReporterCadenceTests
Assert.Contains("pending", warning.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("pending", warning.Message, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>Verifies that a fresh or empty pending backlog does not log a warning.</summary>
[Fact] [Fact]
public void FreshOrEmptyPendingBacklog_IsNotWarned() public void FreshOrEmptyPendingBacklog_IsNotWarned()
{ {
@@ -163,12 +173,27 @@ public class SiteAuditBacklogReporterCadenceTests
/// <summary>Captures log entries so the stale-pending signal can be asserted.</summary> /// <summary>Captures log entries so the stale-pending signal can be asserted.</summary>
private sealed class CapturingLogger : ILogger<SiteAuditBacklogReporter> private sealed class CapturingLogger : ILogger<SiteAuditBacklogReporter>
{ {
/// <summary>Gets the log entries captured so far.</summary>
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new(); public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
/// <summary>Returns no scope; the fake logger 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 or a no-op scope.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level being queried.</param>
/// <returns>Always true.</returns>
public bool IsEnabled(LogLevel logLevel) => true; 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 log state.</param>
/// <param name="exception">The associated exception, if any.</param>
/// <param name="formatter">Formats the state and exception into the message.</param>
public void Log<TState>( public void Log<TState>(
LogLevel logLevel, LogLevel logLevel,
EventId eventId, EventId eventId,
@@ -3,13 +3,16 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Tests for <see cref="SiteAuditRetentionOptions"/> clamping (PLAN-04 Task 2). /// Tests for <see cref="SiteAuditRetentionOptions"/> clamping.
/// The resolved knobs guard the daily site retention job against misconfiguration: /// The resolved knobs guard the daily site retention job against misconfiguration:
/// retention days are clamped to [1, 90]; the purge interval is clamped away from /// retention days are clamped to [1, 90]; the purge interval is clamped away from
/// TimeSpan.Zero (an Akka/Timer spin footgun). /// TimeSpan.Zero (an Akka/Timer spin footgun).
/// </summary> /// </summary>
public class SiteAuditRetentionOptionsTests public class SiteAuditRetentionOptionsTests
{ {
/// <summary>Verifies that resolved retention days, clamps.</summary>
/// <param name="configured">Test case input.</param>
/// <param name="expected">Test case input.</param>
[Theory] [Theory]
[InlineData(0, 1)] // clamp floor [InlineData(0, 1)] // clamp floor
[InlineData(7, 7)] // default passthrough [InlineData(7, 7)] // default passthrough
@@ -17,6 +20,7 @@ public class SiteAuditRetentionOptionsTests
public void ResolvedRetentionDays_Clamps(int configured, int expected) public void ResolvedRetentionDays_Clamps(int configured, int expected)
=> Assert.Equal(expected, new SiteAuditRetentionOptions { RetentionDays = configured }.ResolvedRetentionDays); => Assert.Equal(expected, new SiteAuditRetentionOptions { RetentionDays = configured }.ResolvedRetentionDays);
/// <summary>Verifies that defaults, are, seven days, daily purge, five minute initial delay.</summary>
[Fact] [Fact]
public void Defaults_Are_SevenDays_DailyPurge_FiveMinuteInitialDelay() public void Defaults_Are_SevenDays_DailyPurge_FiveMinuteInitialDelay()
{ {
@@ -26,6 +30,7 @@ public class SiteAuditRetentionOptionsTests
Assert.Equal(TimeSpan.FromMinutes(5), o.InitialDelay); Assert.Equal(TimeSpan.FromMinutes(5), o.InitialDelay);
} }
/// <summary>Verifies that resolved purge interval, clamps zero to one minute.</summary>
[Fact] [Fact]
public void ResolvedPurgeInterval_ClampsZeroToOneMinute() public void ResolvedPurgeInterval_ClampsZeroToOneMinute()
=> Assert.Equal(TimeSpan.FromMinutes(1), => Assert.Equal(TimeSpan.FromMinutes(1),
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// </summary> /// </summary>
public class SiteAuditRetentionOptionsValidatorTests public class SiteAuditRetentionOptionsValidatorTests
{ {
/// <summary>Verifies that the default options pass validation.</summary>
[Fact] [Fact]
public void DefaultOptions_AreValid() public void DefaultOptions_AreValid()
{ {
@@ -19,6 +20,7 @@ public class SiteAuditRetentionOptionsValidatorTests
Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded); Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded);
} }
/// <summary>Verifies that a retention period of zero days fails validation.</summary>
[Fact] [Fact]
public void ZeroRetentionDays_Fails() public void ZeroRetentionDays_Fails()
{ {
@@ -29,6 +31,7 @@ public class SiteAuditRetentionOptionsValidatorTests
f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal)); f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal));
} }
/// <summary>Verifies that a purge interval of zero fails validation.</summary>
[Fact] [Fact]
public void ZeroPurgeInterval_Fails() public void ZeroPurgeInterval_Fails()
{ {
@@ -40,6 +43,7 @@ public class SiteAuditRetentionOptionsValidatorTests
f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal)); f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal));
} }
/// <summary>Verifies that a negative initial delay fails validation.</summary>
[Fact] [Fact]
public void NegativeInitialDelay_Fails() public void NegativeInitialDelay_Fails()
{ {
@@ -10,7 +10,7 @@ using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Tests for <see cref="SiteAuditRetentionService"/> (PLAN-04 Task 3). The hosted /// Tests for <see cref="SiteAuditRetentionService"/> The hosted
/// service runs the site SQLite retention purge on a timer: first tick after /// service runs the site SQLite retention purge on a timer: first tick after
/// <see cref="SiteAuditRetentionOptions.InitialDelay"/>, then every /// <see cref="SiteAuditRetentionOptions.InitialDelay"/>, then every
/// <see cref="SiteAuditRetentionOptions.ResolvedPurgeInterval"/>, passing a cutoff of /// <see cref="SiteAuditRetentionOptions.ResolvedPurgeInterval"/>, passing a cutoff of
@@ -19,10 +19,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// </summary> /// </summary>
public class SiteAuditRetentionServiceTests public class SiteAuditRetentionServiceTests
{ {
/// <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] [Fact]
public async Task StopAsync_AfterDispose_DoesNotThrow() public async Task StopAsync_AfterDispose_DoesNotThrow()
{ {
// Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync // Regression: Dispose tears down the CTS StopAsync
// cancels, and the host does not guarantee StopAsync is driven before the DI // cancels, and the host does not guarantee StopAsync is driven before the DI
// container is disposed. Cancel() on a disposed CTS throws, and letting that // container is disposed. Cancel() on a disposed CTS throws, and letting that
// escape an IHostedService aborts the host's whole shutdown sequence. // escape an IHostedService aborts the host's whole shutdown sequence.
@@ -43,6 +45,8 @@ public class SiteAuditRetentionServiceTests
svc.Dispose(); svc.Dispose();
} }
/// <summary>Verifies that a tick purges events older than the retention cutoff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Tick_Purges_With_RetentionCutoff() public async Task Tick_Purges_With_RetentionCutoff()
{ {
@@ -66,6 +70,8 @@ public class SiteAuditRetentionServiceTests
DateTime.UtcNow.AddDays(-7).AddMinutes(1)); DateTime.UtcNow.AddDays(-7).AddMinutes(1));
} }
/// <summary>Verifies that a failing tick is swallowed and later ticks still run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Tick_SwallowsExceptions_AndKeepsTicking() public async Task Tick_SwallowsExceptions_AndKeepsTicking()
{ {
@@ -87,6 +93,8 @@ public class SiteAuditRetentionServiceTests
Assert.True(queue.PurgeCalls.Count >= 2); Assert.True(queue.PurgeCalls.Count >= 2);
} }
/// <summary>Verifies that stopping the service during an in-flight purge completes cleanly without surfacing a cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation() public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation()
{ {
@@ -127,6 +135,13 @@ public class SiteAuditRetentionServiceTests
/// <summary>Stub queue that records purge cutoffs and can throw on the first tick.</summary> /// <summary>Stub queue that records purge cutoffs and can throw on the first tick.</summary>
private sealed class RecordingSiteAuditQueue : ISiteAuditQueue private sealed class RecordingSiteAuditQueue : ISiteAuditQueue
{ {
/// <summary>Not supported by this stub.</summary>
/// <param name="executionIds">The execution ids to read.</param>
/// <param name="sinceUtc">The lower time bound.</param>
/// <param name="limit">The maximum number of rows.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <param name="afterEventId">The keyset cursor.</param>
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync( public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null) IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
=> throw new NotSupportedException("not exercised by the retention service"); => throw new NotSupportedException("not exercised by the retention service");
@@ -134,13 +149,17 @@ public class SiteAuditRetentionServiceTests
private readonly ConcurrentQueue<DateTime> _purgeCalls = new(); private readonly ConcurrentQueue<DateTime> _purgeCalls = new();
private int _calls; private int _calls;
/// <summary>Gets the cutoffs passed to each purge call, in call order.</summary>
public IReadOnlyCollection<DateTime> PurgeCalls => _purgeCalls; public IReadOnlyCollection<DateTime> PurgeCalls => _purgeCalls;
/// <summary>Gets a value indicating whether the first purge call throws a simulated transient failure.</summary>
public bool ThrowOnFirstCall { get; init; } public bool ThrowOnFirstCall { get; init; }
/// <summary>When set, PurgeExpiredAsync records the call then blocks until the token /// <summary>When set, PurgeExpiredAsync records the call then blocks until the token
/// cancels — simulating a shutdown that lands while a purge is in flight.</summary> /// cancels — simulating a shutdown that lands while a purge is in flight.</summary>
public bool BlockUntilCancelled { get; init; } public bool BlockUntilCancelled { get; init; }
// Test stub: Records the cutoff and optionally throws or blocks, as configured.
/// <inheritdoc />
public async Task<int> PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) public async Task<int> PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default)
{ {
var n = Interlocked.Increment(ref _calls); var n = Interlocked.Increment(ref _calls);
@@ -158,19 +177,41 @@ public class SiteAuditRetentionServiceTests
return 0; return 0;
} }
/// <summary>Not supported by this stub.</summary>
/// <param name="limit">The maximum number of rows.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default) public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <summary>Not supported by this stub.</summary>
/// <param name="limit">The maximum number of rows.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default) public Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
// Test stub: Not supported by this stub.
/// <inheritdoc />
public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default) public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <summary>Not supported by this stub.</summary>
/// <param name="sinceUtc">The lower time bound.</param>
/// <param name="batchSize">The maximum batch size.</param>
/// <param name="afterId">The keyset cursor.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync( public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default) DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
// Test stub: Not supported by this stub.
/// <inheritdoc />
public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default) public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
// Test stub: Not supported by this stub.
/// <inheritdoc />
public Task<int> MarkReconciledUpToAsync(DateTime sinceUtc, string? afterId, CancellationToken ct = default) public Task<int> MarkReconciledUpToAsync(DateTime sinceUtc, string? afterId, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
// Test stub: Not supported by this stub.
/// <inheritdoc />
public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default) public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
} }
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// </summary> /// </summary>
public class SiteAuditTelemetryOptionsValidatorTests public class SiteAuditTelemetryOptionsValidatorTests
{ {
/// <summary>Verifies that default options are valid.</summary>
[Fact] [Fact]
public void DefaultOptions_AreValid() public void DefaultOptions_AreValid()
{ {
@@ -18,6 +19,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded); Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded);
} }
/// <summary>Verifies that zero batch size fails.</summary>
[Fact] [Fact]
public void ZeroBatchSize_Fails() public void ZeroBatchSize_Fails()
{ {
@@ -28,6 +30,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal)); f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal));
} }
/// <summary>Verifies that zero busy interval fails.</summary>
[Fact] [Fact]
public void ZeroBusyInterval_Fails() public void ZeroBusyInterval_Fails()
{ {
@@ -38,6 +41,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal)); f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal));
} }
/// <summary>Verifies that zero idle interval fails.</summary>
[Fact] [Fact]
public void ZeroIdleInterval_Fails() public void ZeroIdleInterval_Fails()
{ {
@@ -48,6 +52,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal)); f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
} }
/// <summary>Verifies that idle shorter than busy fails.</summary>
[Fact] [Fact]
public void IdleShorterThanBusy_Fails() public void IdleShorterThanBusy_Fails()
{ {
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// Bundle E (M6-T6) tests for <see cref="SqliteAuditWriter.GetBacklogStatsAsync"/>. /// Tests for <see cref="SqliteAuditWriter.GetBacklogStatsAsync"/>.
/// Exercises the health-metric surface that <c>SiteAuditBacklogReporter</c> /// Exercises the health-metric surface that <c>SiteAuditBacklogReporter</c>
/// polls every 30 s and pushes onto the site health report as /// polls every 30 s and pushes onto the site health report as
/// <c>SiteAuditBacklog</c>. /// <c>SiteAuditBacklog</c>.
@@ -18,6 +18,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
{ {
private readonly string _dbPath; private readonly string _dbPath;
/// <summary>Creates a unique temporary database path for each test.</summary>
public SqliteAuditWriterBacklogStatsTests() public SqliteAuditWriterBacklogStatsTests()
{ {
// OnDiskBytes assertions only make sense against a real file — the // OnDiskBytes assertions only make sense against a real file — the
@@ -28,6 +29,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
$"audit-backlog-stats-{Guid.NewGuid():N}.db"); $"audit-backlog-stats-{Guid.NewGuid():N}.db");
} }
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() public void Dispose()
{ {
if (File.Exists(_dbPath)) if (File.Exists(_dbPath))
@@ -52,6 +54,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
kind: AuditKind.ApiCall, kind: AuditKind.ApiCall,
status: AuditStatus.Delivered); status: AuditStatus.Delivered);
/// <summary>Verifies that an empty database reports zero pending events, no oldest-pending timestamp and zero bytes on disk.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes() public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
{ {
@@ -70,6 +74,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
$"OnDiskBytes must be non-negative, got {snapshot.OnDiskBytes}"); $"OnDiskBytes must be non-negative, got {snapshot.OnDiskBytes}");
} }
/// <summary>Verifies that five pending events are reported as a pending count of five.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Pending_5_Returns_5() public async Task Pending_5_Returns_5()
{ {
@@ -85,6 +91,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
Assert.Equal(5, snapshot.PendingCount); Assert.Equal(5, snapshot.PendingCount);
} }
/// <summary>Verifies that the oldest-pending timestamp is the earliest <c>OccurredAtUtc</c> among the pending events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task OldestPending_Is_Earliest_OccurredAtUtc() public async Task OldestPending_Is_Earliest_OccurredAtUtc()
{ {
@@ -110,10 +118,12 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
Assert.Equal(t1, snapshot.OldestPendingUtc!.Value); Assert.Equal(t1, snapshot.OldestPendingUtc!.Value);
} }
/// <summary>Verifies that reading the backlog stats does not block while a concurrent write load is running.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad() public async Task GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad()
{ {
// AuditLog-005: GetBacklogStatsAsync previously took _writeLock, the // GetBacklogStatsAsync previously took _writeLock, the
// same lock that serialises every batch INSERT in FlushBatch. Under a // same lock that serialises every batch INSERT in FlushBatch. Under a
// backlog growing to hundreds of thousands of rows a COUNT(*)+MIN // backlog growing to hundreds of thousands of rows a COUNT(*)+MIN
// index scan could park the hot-path writer for hundreds of ms. The // index scan could park the hot-path writer for hundreds of ms. The
@@ -163,6 +173,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
$"backlog probe should see at least the seeded rows; got {snapshot.PendingCount}"); $"backlog probe should see at least the seeded rows; got {snapshot.PendingCount}");
} }
/// <summary>Verifies that the on-disk byte count matches the size of the database file.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task OnDiskBytes_ReturnsFileSize() public async Task OnDiskBytes_ReturnsFileSize()
{ {
@@ -14,7 +14,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// arch-review 2026-08-19 P4-2 regression coverage. <see cref="SqliteAuditWriter.FlushBatch"/> /// Regression coverage. <see cref="SqliteAuditWriter.FlushBatch"/>
/// used to call <c>pending.Completion.TrySetResult()</c> per row BEFORE /// used to call <c>pending.Completion.TrySetResult()</c> per row BEFORE
/// <c>transaction.Commit()</c> ran. A later failure in the SAME batch — another row's insert /// <c>transaction.Commit()</c> ran. A later failure in the SAME batch — another row's insert
/// throwing, or the commit itself failing (SQLITE_FULL, IOERR) — rolled the whole transaction /// throwing, or the commit itself failing (SQLITE_FULL, IOERR) — rolled the whole transaction
@@ -102,6 +102,8 @@ public class SqliteAuditWriterCommitFailureTests
return (writer, dataSource); return (writer, dataSource);
} }
/// <summary>Verifies that a mid-batch insert failure faults every row in the batch, not just the failing row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task FlushBatch_MidBatchInsertFailure_FaultsEveryRowInBatch_NotJustTheFailingRow() public async Task FlushBatch_MidBatchInsertFailure_FaultsEveryRowInBatch_NotJustTheFailingRow()
{ {
@@ -152,6 +154,8 @@ public class SqliteAuditWriterCommitFailureTests
Assert.Equal(0L, (long)countCmd.ExecuteScalar()!); Assert.Equal(0L, (long)countCmd.ExecuteScalar()!);
} }
/// <summary>Verifies that the fallback writer rings every faulted row of a mid-batch insert failure and increments the failure counter once per row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task FallbackAuditWriter_MidBatchInsertFailure_RingsEveryFaultedRow_AndIncrementsFailureCounterForEach() public async Task FallbackAuditWriter_MidBatchInsertFailure_RingsEveryFaultedRow_AndIncrementsFailureCounterForEach()
{ {
@@ -10,8 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// non-positive value has a documented fall-back-to-30s contract. /// non-positive value has a documented fall-back-to-30s contract.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation /// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation): it defaults to <c>""</c> (no CWD-relative default — mirrors
/// WP1.2): it defaults to <c>""</c> (no CWD-relative default — mirrors
/// <c>LocalDb:Path</c>), but this validator runs on both Central and Site /// <c>LocalDb:Path</c>), but this validator runs on both Central and Site
/// composition roots via <c>ValidateOnStart</c>, and only Site nodes ever resolve /// composition roots via <c>ValidateOnStart</c>, and only Site nodes ever resolve
/// the writer. The Site-only requirement is enforced pre-host by /// the writer. The Site-only requirement is enforced pre-host by
@@ -20,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// </remarks> /// </remarks>
public class SqliteAuditWriterOptionsValidatorTests public class SqliteAuditWriterOptionsValidatorTests
{ {
/// <summary>Verifies that the default options pass validation.</summary>
[Fact] [Fact]
public void DefaultOptions_AreValid() public void DefaultOptions_AreValid()
{ {
@@ -29,6 +29,7 @@ public class SqliteAuditWriterOptionsValidatorTests
Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded); Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded);
} }
/// <summary>Verifies that a channel capacity of zero fails validation.</summary>
[Fact] [Fact]
public void ZeroChannelCapacity_Fails() public void ZeroChannelCapacity_Fails()
{ {
@@ -39,6 +40,7 @@ public class SqliteAuditWriterOptionsValidatorTests
f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal)); f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal));
} }
/// <summary>Verifies that a batch size of zero fails validation.</summary>
[Fact] [Fact]
public void ZeroBatchSize_Fails() public void ZeroBatchSize_Fails()
{ {
@@ -49,6 +51,7 @@ public class SqliteAuditWriterOptionsValidatorTests
f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal)); f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal));
} }
/// <summary>Verifies that a flush interval of zero fails validation.</summary>
[Fact] [Fact]
public void ZeroFlushInterval_Fails() public void ZeroFlushInterval_Fails()
{ {
@@ -59,6 +62,8 @@ public class SqliteAuditWriterOptionsValidatorTests
f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal)); f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal));
} }
/// <summary>Verifies that a non-positive backlog poll interval still passes validation.</summary>
/// <param name="value">The non-positive poll interval, in milliseconds, to validate.</param>
[Theory] [Theory]
[InlineData(0)] [InlineData(0)]
[InlineData(-1)] [InlineData(-1)]
@@ -19,6 +19,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db"); private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db");
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc); private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose() public void Dispose()
{ {
if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } } if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } }
@@ -33,6 +34,9 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered, ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a"); occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
/// <summary>Verifies that the trace read plan uses the execution-id indexes instead of scanning the retained store.</summary>
/// <param name="idCount">The number of execution ids included in the read.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData(1)] [InlineData(1)]
[InlineData(64)] [InlineData(64)]
@@ -56,6 +60,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal)); Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal));
} }
/// <summary>Verifies that legacy rows with malformed details JSON do not break startup or trace reads.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads() public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads()
{ {
@@ -81,6 +87,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId); Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId);
} }
/// <summary>Verifies that the cursor pages through rows with equal timestamps without repeating or skipping any.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows() public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows()
{ {
@@ -100,6 +108,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
first.Concat(second).Select(e => e.EventId)); first.Concat(second).Select(e => e.EventId));
} }
/// <summary>Verifies that rows matching either the execution id or the parent execution id are returned in order and within the bound.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded() public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
{ {
@@ -145,6 +155,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
/// ApiOutbound row (or any other channel) stored under the execution id is served alongside /// ApiOutbound row (or any other channel) stored under the execution id is served alongside
/// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in. /// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ServesOutboundRows_NoChannelPredicate() public async Task ServesOutboundRows_NoChannelPredicate()
{ {
@@ -171,6 +182,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel); Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel);
} }
/// <summary>Verifies that the read guards reject an invalid limit and an oversized id list.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Guards_LimitAndIdCap() public async Task Guards_LimitAndIdCap()
{ {
@@ -22,12 +22,14 @@ public class SqliteAuditWriterRetentionTests : IDisposable
{ {
private readonly string _dbPath; private readonly string _dbPath;
/// <summary>Initializes the test class with a unique temporary database path.</summary>
public SqliteAuditWriterRetentionTests() public SqliteAuditWriterRetentionTests()
{ {
_dbPath = Path.Combine(Path.GetTempPath(), _dbPath = Path.Combine(Path.GetTempPath(),
$"audit-retention-{Guid.NewGuid():N}.db"); $"audit-retention-{Guid.NewGuid():N}.db");
} }
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose() public void Dispose()
{ {
if (File.Exists(_dbPath)) if (File.Exists(_dbPath))
@@ -58,6 +60,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable
return id; return id;
} }
/// <summary>Verifies that purging deletes forwarded and reconciled rows older than the cutoff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task PurgeExpired_DeletesForwardedAndReconciled_OlderThanCutoff() public async Task PurgeExpired_DeletesForwardedAndReconciled_OlderThanCutoff()
{ {
@@ -80,6 +84,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable
Assert.Equal(1, stats.PendingCount); Assert.Equal(1, stats.PendingCount);
} }
/// <summary>Verifies that purging expired rows is idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task PurgeExpired_IsIdempotent() public async Task PurgeExpired_IsIdempotent()
{ {
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// C4 (Task 2.5) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>'s /// Schema-bootstrap tests for <see cref="SqliteAuditWriter"/>'s
/// two-table site schema — the append-only canonical <c>audit_event</c> table + /// two-table site schema — the append-only canonical <c>audit_event</c> table +
/// the mutable operational <c>audit_forward_state</c> sidecar + the <c>IX_fwd</c> /// the mutable operational <c>audit_forward_state</c> sidecar + the <c>IX_fwd</c>
/// drain index. Uses an in-memory shared-cache SQLite database so the same /// drain index. Uses an in-memory shared-cache SQLite database so the same
@@ -80,6 +80,7 @@ public class SqliteAuditWriterSchemaTests
return Convert.ToInt32(cmd.ExecuteScalar()) > 0; return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
} }
/// <summary>Verifies that opens, creates, audit, event, canonical, table, with, 10 columns, and, pk, on, event id.</summary>
[Fact] [Fact]
public void Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId() public void Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId()
{ {
@@ -113,6 +114,7 @@ public class SqliteAuditWriterSchemaTests
} }
} }
/// <summary>Verifies that opens, creates, audit, forward, state, sidecar, table, with, expected, columns.</summary>
[Fact] [Fact]
public void Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns() public void Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns()
{ {
@@ -145,6 +147,7 @@ public class SqliteAuditWriterSchemaTests
} }
} }
/// <summary>Verifies that opens, creates, ix, fwd, index, on, forward state, is cached kind, occurred.</summary>
[Fact] [Fact]
public void Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred() public void Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred()
{ {
@@ -180,6 +183,7 @@ public class SqliteAuditWriterSchemaTests
} }
} }
/// <summary>Verifies that pragma, auto, vacuum, is, incremental.</summary>
[Fact] [Fact]
public void PRAGMA_auto_vacuum_Is_INCREMENTAL() public void PRAGMA_auto_vacuum_Is_INCREMENTAL()
{ {
@@ -265,6 +269,8 @@ public class SqliteAuditWriterSchemaTests
return connection; return connection;
} }
/// <summary>Verifies that opening, over, pre existing, old single table, db, drops, it, and, creates, two, table, schema.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema() public async Task Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema()
{ {
@@ -316,6 +322,8 @@ public class SqliteAuditWriterSchemaTests
// ----- Canonical / sidecar field persistence ----- // // ----- Canonical / sidecar field persistence ----- //
/// <summary>Verifies that write async, persists, source node, field.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_persists_SourceNode_field() public async Task WriteAsync_persists_SourceNode_field()
{ {
@@ -337,6 +345,8 @@ public class SqliteAuditWriterSchemaTests
} }
} }
/// <summary>Verifies that write async, persists, null, source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_persists_null_SourceNode() public async Task WriteAsync_persists_null_SourceNode()
{ {
@@ -358,6 +368,8 @@ public class SqliteAuditWriterSchemaTests
} }
} }
/// <summary>Verifies that write async, execution id, round trips, through, details json.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson() public async Task WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson()
{ {
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary> /// <summary>
/// C4 (Task 2.5) hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s /// Hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s
/// two-table site schema. Exercise the Channel-based enqueue, the background /// two-table site schema. Exercise the Channel-based enqueue, the background
/// writer's per-event canonical(<c>audit_event</c>) + sidecar /// writer's per-event canonical(<c>audit_event</c>) + sidecar
/// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the /// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the
@@ -87,7 +87,7 @@ public class SqliteAuditWriterWriteTests
return byState; return byState;
} }
// C4 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared // Build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// factory. The SQLite writer stores the 10 canonical fields directly in // factory. The SQLite writer stores the 10 canonical fields directly in
// audit_event and writes a Pending sidecar row into audit_forward_state, with // audit_event and writes a Pending sidecar row into audit_forward_state, with
// IsCachedKind precomputed from the event's Kind. Reads recompose the canonical // IsCachedKind precomputed from the event's Kind. Reads recompose the canonical
@@ -120,6 +120,8 @@ public class SqliteAuditWriterWriteTests
eventId: id ?? Guid.NewGuid(), eventId: id ?? Guid.NewGuid(),
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow); occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow);
/// <summary>Verifies that a fresh event persists its canonical fields and a Pending sidecar row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending() public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending()
{ {
@@ -140,6 +142,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId)); Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
} }
/// <summary>Verifies that the canonical fields written for an event round-trip unchanged through a read.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read() public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read()
{ {
@@ -164,6 +168,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(evt.DetailsJson, row.DetailsJson); Assert.Equal(evt.DetailsJson, row.DetailsJson);
} }
/// <summary>Verifies that 1000 concurrent writes all persist without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions() public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions()
{ {
@@ -186,16 +192,17 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar())); Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
} }
// ----- FlushIntervalMs soft-flush coalescing (arch-review remediation WP1.2) ----- // // ----- FlushIntervalMs soft-flush coalescing (arch-review remediation) ----- //
/// <summary> /// <summary>
/// A trickle of events — well under <see cref="SqliteAuditWriterOptions.BatchSize"/>, /// A trickle of events — well under <see cref="SqliteAuditWriterOptions.BatchSize"/>,
/// spaced closer together than <see cref="SqliteAuditWriterOptions.FlushIntervalMs"/> — /// spaced closer together than <see cref="SqliteAuditWriterOptions.FlushIntervalMs"/> —
/// must coalesce into ONE transaction rather than fsyncing once per event. This is the /// must coalesce into ONE transaction rather than fsyncing once per event. This is the
/// bug WP1.2 fixes: <c>FlushIntervalMs</c> was validated but never read by the writer /// bug this fixes: <c>FlushIntervalMs</c> was validated but never read by the writer
/// loop, so every trickle-rate write incurred its own commit (and, under SQLite's /// loop, so every trickle-rate write incurred its own commit (and, under SQLite's
/// default synchronous=FULL, its own fsync). /// default synchronous=FULL, its own fsync).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_TrickleLoad_CoalescesIntoOneTransaction() public async Task WriteAsync_TrickleLoad_CoalescesIntoOneTransaction()
{ {
@@ -229,6 +236,7 @@ public class SqliteAuditWriterWriteTests
/// waiting for a partner — the deadline bounds worst-case added latency, it does /// waiting for a partner — the deadline bounds worst-case added latency, it does
/// not turn into an unbounded debounce. /// not turn into an unbounded debounce.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_EventsSpacedBeyondFlushInterval_FlushSeparately() public async Task WriteAsync_EventsSpacedBeyondFlushInterval_FlushSeparately()
{ {
@@ -249,6 +257,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(2, writer.FlushCountForTests); Assert.Equal(2, writer.FlushCountForTests);
} }
/// <summary>Verifies that a duplicate event id is swallowed without an exception and the first write wins.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException() public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException()
{ {
@@ -281,13 +291,15 @@ public class SqliteAuditWriterWriteTests
Assert.Equal("first", targetCmd.ExecuteScalar() as string); Assert.Equal("first", targetCmd.ExecuteScalar() as string);
} }
/// <summary>Verifies that a write always sets the sidecar forward state to Pending.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_ForcesSidecarForwardStatePending() public async Task WriteAsync_ForcesSidecarForwardStatePending()
{ {
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending)); var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending));
await using var _ = writer; await using var _ = writer;
// C4 (Task 2.5): ForwardState is not a field on the canonical record; a // ForwardState is not a field on the canonical record; a
// fresh event's sidecar row defaults to Pending on INSERT. // fresh event's sidecar row defaults to Pending on INSERT.
var evt = NewEvent(); var evt = NewEvent();
await writer.WriteAsync(evt); await writer.WriteAsync(evt);
@@ -297,6 +309,8 @@ public class SqliteAuditWriterWriteTests
// ----- IsCachedKind drain split (precomputed at insert) ----- // // ----- IsCachedKind drain split (precomputed at insert) ----- //
/// <summary>Verifies that a cached audit kind sets the cached-kind flag to 1 and a non-cached kind sets it to 0.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0() public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0()
{ {
@@ -320,6 +334,9 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar())); Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
} }
/// <summary>Verifies that cached audit kinds drain through the cached-telemetry read and not the ordinary pending read.</summary>
/// <param name="kind">The cached audit kind under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData(AuditKind.CachedSubmit)] [InlineData(AuditKind.CachedSubmit)]
[InlineData(AuditKind.ApiCallCached)] [InlineData(AuditKind.ApiCallCached)]
@@ -342,6 +359,8 @@ public class SqliteAuditWriterWriteTests
Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId); Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId);
} }
/// <summary>Verifies that a non-cached audit kind drains through the ordinary pending read and not the cached-telemetry read.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry() public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry()
{ {
@@ -358,6 +377,8 @@ public class SqliteAuditWriterWriteTests
Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId); Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId);
} }
/// <summary>Verifies that the pending read returns the oldest rows first, limited to the requested count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN() public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN()
{ {
@@ -387,6 +408,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc); Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
} }
/// <summary>Verifies that marking events as forwarded flips their sidecar rows to Forwarded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded() public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded()
{ {
@@ -406,6 +429,8 @@ public class SqliteAuditWriterWriteTests
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString())); Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
} }
/// <summary>Verifies that marking events as forwarded increments the attempt count and stamps the last-attempt time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc() public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc()
{ {
@@ -428,6 +453,8 @@ public class SqliteAuditWriterWriteTests
Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped
} }
/// <summary>Verifies that marking a non-existent event id as forwarded does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkForwardedAsync_NonExistentId_NoThrow() public async Task MarkForwardedAsync_NonExistentId_NoThrow()
{ {
@@ -440,6 +467,8 @@ public class SqliteAuditWriterWriteTests
// No assertion needed: the call must complete without throwing. // No assertion needed: the call must complete without throwing.
} }
/// <summary>Verifies that the forwarded read returns only rows in the Forwarded state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows() public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows()
{ {
@@ -457,8 +486,10 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(forwarded.EventId, row.EventId); Assert.Equal(forwarded.EventId, row.EventId);
} }
// ----- M6 reconciliation pull surface ----- // // ----- Reconciliation pull surface ----- //
/// <summary>Verifies that the since-read returns pending and forwarded rows oldest first, limited to the requested count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN() public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN()
{ {
@@ -488,6 +519,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc); Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
} }
/// <summary>Verifies that the since-read excludes rows older than the supplied cutoff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc() public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc()
{ {
@@ -511,6 +544,8 @@ public class SqliteAuditWriterWriteTests
Assert.DoesNotContain(rows, r => r.EventId == old.EventId); Assert.DoesNotContain(rows, r => r.EventId == old.EventId);
} }
/// <summary>Verifies that the since-read excludes rows that are already reconciled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadPendingSinceAsync_ExcludesReconciledRows() public async Task ReadPendingSinceAsync_ExcludesReconciledRows()
{ {
@@ -531,8 +566,10 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(pending.EventId, rows[0].EventId); Assert.Equal(pending.EventId, rows[0].EventId);
} }
// ----- WP2.3: composite keyset cursor + cursor-proved retirement ----- // // ----- Composite keyset cursor + cursor-proved retirement ----- //
/// <summary>Verifies that supplying an after-id skips rows at or before the composite timestamp-and-id cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadPendingSinceAsync_WithAfterId_SkipsRowsAtOrBeforeTheCompositeCursor() public async Task ReadPendingSinceAsync_WithAfterId_SkipsRowsAtOrBeforeTheCompositeCursor()
{ {
@@ -567,6 +604,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(sameInstant.Select(e => e.EventId).ToHashSet(), allIds); Assert.Equal(sameInstant.Select(e => e.EventId).ToHashSet(), allIds);
} }
/// <summary>Verifies that reconciling up to a cursor with an id retires only rows at or before that cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkReconciledUpToAsync_WithCursorId_RetiresOnlyRowsAtOrBeforeIt() public async Task MarkReconciledUpToAsync_WithCursorId_RetiresOnlyRowsAtOrBeforeIt()
{ {
@@ -599,6 +638,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(3, remaining.Count); // the two un-consumed at the instant + newer Assert.Equal(3, remaining.Count); // the two un-consumed at the instant + newer
} }
/// <summary>Verifies that reconciling up to a timestamp without a cursor id leaves rows at the boundary instant servable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkReconciledUpToAsync_WithoutCursorId_LeavesTheBoundaryInstantServable() public async Task MarkReconciledUpToAsync_WithoutCursorId_LeavesTheBoundaryInstantServable()
{ {
@@ -626,6 +667,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(boundary.EventId, row.EventId); Assert.Equal(boundary.EventId, row.EventId);
} }
/// <summary>Verifies that rows served without an advancing cursor remain servable so they are delivered at least once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ServingRows_WithoutAnAdvancingCursor_KeepsThemServable_AtLeastOnce() public async Task ServingRows_WithoutAnAdvancingCursor_KeepsThemServable_AtLeastOnce()
{ {
@@ -652,6 +695,8 @@ public class SqliteAuditWriterWriteTests
second.Select(r => r.EventId).ToHashSet()); second.Select(r => r.EventId).ToHashSet());
} }
/// <summary>Verifies that a late-stamped insert below the cursor is never retired by reconciling up to the cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkReconciledUpToAsync_LateStampedInsertBelowTheCursor_IsNeverRetired() public async Task MarkReconciledUpToAsync_LateStampedInsertBelowTheCursor_IsNeverRetired()
{ {
@@ -695,6 +740,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(lateStamped.EventId, Assert.Single(reread).EventId); Assert.Equal(lateStamped.EventId, Assert.Single(reread).EventId);
} }
/// <summary>Verifies that forwarded rows are retired by reconciling up to a cursor even if they were never pulled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkReconciledUpToAsync_ForwardedRowsRetire_EvenWithoutHavingBeenPulled() public async Task MarkReconciledUpToAsync_ForwardedRowsRetire_EvenWithoutHavingBeenPulled()
{ {
@@ -722,6 +769,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, neverShipped.EventId)); Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, neverShipped.EventId));
} }
/// <summary>Verifies that reconciling before any pull retires no pending rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkReconciledUpToAsync_BeforeAnyPull_RetiresNothingPending() public async Task MarkReconciledUpToAsync_BeforeAnyPull_RetiresNothingPending()
{ {
@@ -745,6 +794,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(1, await writer.MarkReconciledUpToAsync(t0.AddHours(1), afterId: null)); Assert.Equal(1, await writer.MarkReconciledUpToAsync(t0.AddHours(1), afterId: null));
} }
/// <summary>Verifies that the since-read throws for an invalid batch size.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws() public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws()
{ {
@@ -757,6 +808,8 @@ public class SqliteAuditWriterWriteTests
() => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3)); () => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3));
} }
/// <summary>Verifies that marking events as reconciled flips both pending and forwarded rows to Reconciled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled() public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled()
{ {
@@ -781,6 +834,8 @@ public class SqliteAuditWriterWriteTests
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString())); Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
} }
/// <summary>Verifies that marking reconciled is idempotent and leaves already-reconciled rows untouched.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched() public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched()
{ {
@@ -796,6 +851,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId)); Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId));
} }
/// <summary>Verifies that marking a non-existent event id as reconciled does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkReconciledAsync_NonExistentId_NoThrow() public async Task MarkReconciledAsync_NonExistentId_NoThrow()
{ {
@@ -807,7 +864,7 @@ public class SqliteAuditWriterWriteTests
} }
/// <summary> /// <summary>
/// Fix 2 / M1 state guard: <see cref="SqliteAuditWriter.MarkForwardedAsync"/> /// State guard: <see cref="SqliteAuditWriter.MarkForwardedAsync"/>
/// must NOT demote a <see cref="AuditForwardState.Reconciled"/> row back to /// must NOT demote a <see cref="AuditForwardState.Reconciled"/> row back to
/// <see cref="AuditForwardState.Forwarded"/>. When a batch contains both a /// <see cref="AuditForwardState.Forwarded"/>. When a batch contains both a
/// Pending ID and an already-Reconciled ID: /// Pending ID and an already-Reconciled ID:
@@ -818,6 +875,7 @@ public class SqliteAuditWriterWriteTests
/// This mirrors the idempotency guard already present on /// This mirrors the idempotency guard already present on
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/>. /// <see cref="SqliteAuditWriter.MarkReconciledAsync"/>.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions() public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions()
{ {
@@ -879,6 +937,7 @@ public class SqliteAuditWriterWriteTests
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> call flip atomically — and /// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> call flip atomically — and
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> chunks the same way. /// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> chunks the same way.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip() public async Task MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip()
{ {
@@ -912,6 +971,8 @@ public class SqliteAuditWriterWriteTests
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- // // ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
/// <summary>Verifies that a non-null execution id round-trips through a write and read.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_NonNullExecutionId_RoundTrips() public async Task WriteAsync_NonNullExecutionId_RoundTrips()
{ {
@@ -928,6 +989,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(executionId, row.AsRow().ExecutionId); Assert.Equal(executionId, row.AsRow().ExecutionId);
} }
/// <summary>Verifies that a null execution id round-trips as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull() public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
{ {
@@ -945,6 +1008,8 @@ public class SqliteAuditWriterWriteTests
// ----- SourceNode stamping (Tasks 11/12) ----- // // ----- SourceNode stamping (Tasks 11/12) ----- //
/// <summary>Verifies that the source node is stamped from the node identity provider when the event has none.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
{ {
@@ -962,6 +1027,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal("node-a", row.SourceNode); Assert.Equal("node-a", row.SourceNode);
} }
/// <summary>Verifies that a source node supplied by the caller is preserved.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_PreservesCallerProvidedSourceNode() public async Task WriteAsync_PreservesCallerProvidedSourceNode()
{ {
@@ -980,6 +1047,8 @@ public class SqliteAuditWriterWriteTests
Assert.Equal("node-z", row.SourceNode); Assert.Equal("node-z", row.SourceNode);
} }
/// <summary>Verifies that the source node stays null when the provider returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
{ {
@@ -999,13 +1068,14 @@ public class SqliteAuditWriterWriteTests
// ----- C4 hardening: safe enum-parse in MapRow ----- // // ----- C4 hardening: safe enum-parse in MapRow ----- //
/// <summary> /// <summary>
/// C4 hardening (Task 2.5): a row whose stored <c>Outcome</c> column holds an /// C4 hardening: a row whose stored <c>Outcome</c> column holds an
/// unknown/renamed enum string must NOT fault the read path; it degrades /// unknown/renamed enum string must NOT fault the read path; it degrades
/// gracefully to <see cref="AuditOutcome.Success"/> (the safe /// gracefully to <see cref="AuditOutcome.Success"/> (the safe
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is /// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is
/// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/> /// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/>
/// surface which calls the private <c>MapRow</c>. /// surface which calls the private <c>MapRow</c>.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback() public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback()
{ {
@@ -1033,8 +1103,10 @@ public class SqliteAuditWriterWriteTests
Assert.Equal(AuditOutcome.Success, row.Outcome); Assert.Equal(AuditOutcome.Success, row.Outcome);
} }
// ── Task 24: pin the inclusive (>=) reconciliation cursor boundary ── // ── Pin the inclusive (>=) reconciliation cursor boundary ──
/// <summary>Verifies that a row at the boundary timestamp is included in the since-read and that event-id deduplication absorbs the resulting duplicates.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates() public async Task ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates()
{ {
@@ -53,6 +53,8 @@ public class CachedCallLifecycleBridgeTests
SourceScript: sourceScript, SourceScript: sourceScript,
ParentExecutionId: parentExecutionId); ParentExecutionId: parentExecutionId);
/// <summary>Verifies that a transient failure emits one Attempted row and no resolve row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve() public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
{ {
@@ -78,6 +80,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Null(packet.Operational.TerminalAtUtc); Assert.Null(packet.Operational.TerminalAtUtc);
} }
/// <summary>Verifies that a delivered attempt emits an Attempted row and a CachedResolve row with Delivered status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered() public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered()
{ {
@@ -104,6 +108,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId); Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId);
} }
/// <summary>Verifies that a permanent failure emits an Attempted row and a CachedResolve row with Parked status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked() public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked()
{ {
@@ -123,6 +129,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal("Parked", captured[1].Operational.Status); Assert.Equal("Parked", captured[1].Operational.Status);
} }
/// <summary>Verifies that parking after the maximum retries emits an Attempted row and a CachedResolve row with Parked status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked() public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked()
{ {
@@ -138,6 +146,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status); Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
} }
/// <summary>Verifies that the database channel maps to the DbWriteCached kind and the DbOutbound channel.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel() public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel()
{ {
@@ -157,6 +167,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel); Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel);
} }
/// <summary>Verifies that the bridge does not throw when the telemetry forwarder throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task BridgeDoesNotThrow_WhenForwarderThrows() public async Task BridgeDoesNotThrow_WhenForwarderThrows()
{ {
@@ -170,6 +182,8 @@ public class CachedCallLifecycleBridgeTests
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered)); await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
} }
/// <summary>Verifies that the bridge populates provenance fields from the attempt context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task BridgePopulatesProvenance_FromAttemptContext() public async Task BridgePopulatesProvenance_FromAttemptContext()
{ {
@@ -192,12 +206,14 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId); Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId);
} }
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ── // ── ExecutionId / SourceScript ──
/// <summary>Verifies that a retry-loop Attempted row carries the ExecutionId and SourceScript from the attempt context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext() public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext()
{ {
// Task 4: the ExecutionId + SourceScript threaded through the S&F // The ExecutionId + SourceScript threaded through the S&F
// buffer arrive on the CachedCallAttemptContext; the bridge must stamp // buffer arrive on the CachedCallAttemptContext; the bridge must stamp
// both onto the per-attempt ApiCallCached row (previously SourceScript // both onto the per-attempt ApiCallCached row (previously SourceScript
// was hard-coded null with a "not threaded through S&F" comment). // was hard-coded null with a "not threaded through S&F" comment).
@@ -218,6 +234,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript); Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript);
} }
/// <summary>Verifies that a retry-loop CachedResolve row carries the ExecutionId and SourceScript from the attempt context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext() public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
{ {
@@ -245,10 +263,12 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript); Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript);
} }
/// <summary>Verifies that a retry-loop row leaves ExecutionId and SourceScript null when the context has none.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull() public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull()
{ {
// Back-compat: a pre-Task-4 buffered row has no ExecutionId / // Back-compat: a buffered row written before ExecutionId was threaded through has no ExecutionId /
// SourceScript; the bridge must leave the audit row's fields null // SourceScript; the bridge must leave the audit row's fields null
// rather than throwing. // rather than throwing.
CachedCallTelemetry? captured = null; CachedCallTelemetry? captured = null;
@@ -263,12 +283,14 @@ public class CachedCallLifecycleBridgeTests
Assert.Null(captured.Audit.AsRow().SourceScript); Assert.Null(captured.Audit.AsRow().SourceScript);
} }
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ── // ── ParentExecutionId ──
/// <summary>Verifies that a retry-loop Attempted row carries the ParentExecutionId from the attempt context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext() public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext()
{ {
// Task 6: the ParentExecutionId threaded through the S&F buffer (the // The ParentExecutionId threaded through the S&F buffer (the
// inbound-API run that spawned the originating script) arrives on the // inbound-API run that spawned the originating script) arrives on the
// CachedCallAttemptContext; the bridge must stamp it onto the // CachedCallAttemptContext; the bridge must stamp it onto the
// per-attempt ApiCallCached row beside ExecutionId. // per-attempt ApiCallCached row beside ExecutionId.
@@ -287,6 +309,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId); Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
} }
/// <summary>Verifies that a retry-loop CachedResolve row carries the ParentExecutionId from the attempt context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext() public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext()
{ {
@@ -312,6 +336,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId); Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId);
} }
/// <summary>Verifies that a retry-loop row leaves ParentExecutionId null when the context has none.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopRow_NullParentExecutionId_RemainsNull() public async Task RetryLoopRow_NullParentExecutionId_RemainsNull()
{ {
@@ -329,12 +355,14 @@ public class CachedCallLifecycleBridgeTests
Assert.Null(captured!.Audit.AsRow().ParentExecutionId); Assert.Null(captured!.Audit.AsRow().ParentExecutionId);
} }
// ── SourceNode-stamping (Task 14) ── // ── SourceNode stamping ──
/// <summary>Verifies that a retry-loop row stamps the source node name from the node identity provider.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopRow_StampsSourceNode_FromNodeIdentityProvider() public async Task RetryLoopRow_StampsSourceNode_FromNodeIdentityProvider()
{ {
// SourceNode-stamping (Task 14): when an INodeIdentityProvider is // SourceNode stamping: when an INodeIdentityProvider is
// wired the bridge stamps the local node name (node-a/node-b) onto // wired the bridge stamps the local node name (node-a/node-b) onto
// the SiteCallOperational.SourceNode column of every emitted packet. // the SiteCallOperational.SourceNode column of every emitted packet.
var nodeIdentity = Substitute.For<INodeIdentityProvider>(); var nodeIdentity = Substitute.For<INodeIdentityProvider>();
@@ -353,6 +381,8 @@ public class CachedCallLifecycleBridgeTests
Assert.All(captured, p => Assert.Equal("node-a", p.Operational.SourceNode)); Assert.All(captured, p => Assert.Equal("node-a", p.Operational.SourceNode));
} }
/// <summary>Verifies that a retry-loop row leaves the source node null when no node identity provider is configured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull() public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull()
{ {
@@ -371,6 +401,8 @@ public class CachedCallLifecycleBridgeTests
Assert.Null(packet.Operational.SourceNode); Assert.Null(packet.Operational.SourceNode);
} }
/// <summary>Verifies that a retry-loop row leaves the source node null when the node identity has a null node name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull() public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull()
{ {
@@ -111,6 +111,8 @@ public class CachedCallTelemetryForwarderTests
UpdatedAtUtc: _now, UpdatedAtUtc: _now,
TerminalAtUtc: _now)); TerminalAtUtc: _now));
/// <summary>Verifies that forwarding a submit packet writes an audit event and records the enqueue.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue() public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue()
{ {
@@ -130,7 +132,7 @@ public class CachedCallTelemetryForwarderTests
// Tracking row: insert-if-not-exists with kind discriminator. // Tracking row: insert-if-not-exists with kind discriminator.
// Default CreateSut() does NOT supply an INodeIdentityProvider, so the // Default CreateSut() does NOT supply an INodeIdentityProvider, so the
// forwarder passes null sourceNode to RecordEnqueueAsync (legacy / test // forwarder passes null sourceNode to RecordEnqueueAsync (legacy / test
// host behaviour). The Task 14 stamping path is covered by the // host behaviour). The stamping path is covered by the
// ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider test // ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider test
// below. // below.
await _tracking.Received(1).RecordEnqueueAsync( await _tracking.Received(1).RecordEnqueueAsync(
@@ -147,6 +149,8 @@ public class CachedCallTelemetryForwarderTests
default, default!, default, default, default); default, default!, default, default, default);
} }
/// <summary>Verifies that forwarding an attempted packet writes an audit event and records the attempt.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt() public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt()
{ {
@@ -170,6 +174,8 @@ public class CachedCallTelemetryForwarderTests
default, default!, default, default, default); default, default!, default, default, default);
} }
/// <summary>Verifies that forwarding a resolve packet writes an audit event and records the terminal state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal() public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal()
{ {
@@ -196,6 +202,8 @@ public class CachedCallTelemetryForwarderTests
// ── 2026-09-06: the audit row is enqueued, never awaited to commit ───── // ── 2026-09-06: the audit row is enqueued, never awaited to commit ─────
// Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md // Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md
/// <summary>Verifies that forwarding returns while the audit commit is still pending.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_ReturnsWhileTheAuditCommitIsStillPending() public async Task ForwardAsync_ReturnsWhileTheAuditCommitIsStillPending()
{ {
@@ -216,6 +224,8 @@ public class CachedCallTelemetryForwarderTests
_id, "Delivered", Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()); _id, "Delivered", Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that a later audit commit fault is swallowed rather than surfaced to the caller.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_AuditCommitFaultsLater_IsSwallowed() public async Task ForwardAsync_AuditCommitFaultsLater_IsSwallowed()
{ {
@@ -234,6 +244,8 @@ public class CachedCallTelemetryForwarderTests
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>()); Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that a throwing audit writer is logged and the exception does not propagate.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate() public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate()
{ {
@@ -257,6 +269,8 @@ public class CachedCallTelemetryForwarderTests
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that a throwing tracking store is logged and the exception does not propagate.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate() public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate()
{ {
@@ -279,6 +293,8 @@ public class CachedCallTelemetryForwarderTests
Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()); Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that forwarding a null packet throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_NullPacket_Throws() public async Task ForwardAsync_NullPacket_Throws()
{ {
@@ -288,12 +304,14 @@ public class CachedCallTelemetryForwarderTests
() => sut.ForwardAsync(null!, CancellationToken.None)); () => sut.ForwardAsync(null!, CancellationToken.None));
} }
// ── SourceNode-stamping (Task 14) ── // ── SourceNode stamping ──
/// <summary>Verifies that a submit packet is stamped with the source node from the node identity provider.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider() public async Task ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider()
{ {
// SourceNode-stamping (Task 14): when an INodeIdentityProvider is // SourceNode stamping: when an INodeIdentityProvider is
// wired the forwarder must stamp its NodeName onto the // wired the forwarder must stamp its NodeName onto the
// RecordEnqueueAsync sourceNode parameter so the tracking row // RecordEnqueueAsync sourceNode parameter so the tracking row
// captures the originating node (node-a/node-b). // captures the originating node (node-a/node-b).
@@ -315,6 +333,8 @@ public class CachedCallTelemetryForwarderTests
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that a node identity with a null node name results in a null source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode() public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode()
{ {
@@ -4,15 +4,17 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
/// <summary> /// <summary>
/// Bundle E E1 tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp /// Tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until M6 /// client is the default <see cref="ISiteStreamAuditClient"/> binding until the
/// delivers the gRPC-backed implementation; both <c>IngestAuditEventsAsync</c> /// gRPC-backed implementation is delivered; both <c>IngestAuditEventsAsync</c>
/// (M2) and <c>IngestCachedTelemetryAsync</c> (M3) must return an empty ack /// and <c>IngestCachedTelemetryAsync</c> must return an empty ack
/// (no rows flipped to Forwarded) without throwing or partially handling the /// (no rows flipped to Forwarded) without throwing or partially handling the
/// batch. /// batch.
/// </summary> /// </summary>
public class NoOpSiteStreamAuditClientTests public class NoOpSiteStreamAuditClientTests
{ {
/// <summary>Verifies that ingesting an empty cached-telemetry batch returns an empty acknowledgement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck() public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck()
{ {
@@ -25,6 +27,8 @@ public class NoOpSiteStreamAuditClientTests
Assert.Empty(ack.AcceptedEventIds); Assert.Empty(ack.AcceptedEventIds);
} }
/// <summary>Verifies that ingesting a populated batch still returns an empty acknowledgement, because the no-op client forwards nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck() public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck()
{ {
@@ -47,6 +51,8 @@ public class NoOpSiteStreamAuditClientTests
Assert.Empty(ack.AcceptedEventIds); Assert.Empty(ack.AcceptedEventIds);
} }
/// <summary>Verifies that ingesting a null batch throws an <see cref="ArgumentNullException"/>.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestCachedTelemetryAsync_NullBatch_Throws() public async Task IngestCachedTelemetryAsync_NullBatch_Throws()
{ {
@@ -55,7 +55,7 @@ public class SiteAuditTelemetryActorTests : TestKit
(IOperationTrackingStore?)null))); (IOperationTrackingStore?)null)));
/// <summary> /// <summary>
/// AuditLog-001: builds an actor with the optional /// Builds an actor with the optional
/// <see cref="IOperationTrackingStore"/> wired in so the cached-drain /// <see cref="IOperationTrackingStore"/> wired in so the cached-drain
/// scheduler is armed alongside the audit-only drain. Used by the new /// scheduler is armed alongside the audit-only drain. Used by the new
/// cached-drain regression tests below. /// cached-drain regression tests below.
@@ -86,6 +86,9 @@ public class SiteAuditTelemetryActorTests : TestKit
return ack; return ack;
} }
/// <summary>Verifies that a drain of large rows stays within the wire budget and does not acknowledge rows that were not sent.</summary>
/// <param name="cached"><c>true</c> to drain cached-call rows, <c>false</c> to drain audit-only rows.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData(false)] [InlineData(false)]
[InlineData(true)] [InlineData(true)]
@@ -126,6 +129,8 @@ public class SiteAuditTelemetryActorTests : TestKit
Assert.Equal(256, sent.Count); Assert.Equal(256, sent.Count);
} }
/// <summary>Verifies that a drain of 50 pending rows sends one batch of 50 and then marks the rows as forwarded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded() public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded()
{ {
@@ -166,6 +171,8 @@ public class SiteAuditTelemetryActorTests : TestKit
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that rows stay pending when the gRPC call throws and the next drain retries them.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries() public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries()
{ {
@@ -206,6 +213,8 @@ public class SiteAuditTelemetryActorTests : TestKit
Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}"); Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}");
} }
/// <summary>Verifies that a drain with no pending rows schedules the next drain at the idle interval without calling the client.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall() public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall()
{ {
@@ -230,6 +239,8 @@ public class SiteAuditTelemetryActorTests : TestKit
Assert.InRange(readCalls, 1, 2); Assert.InRange(readCalls, 1, 2);
} }
/// <summary>Verifies that a drain that finds pending rows schedules the next drain at the busy interval.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Drain_NonZeroPending_SchedulesAtBusyInterval() public async Task Drain_NonZeroPending_SchedulesAtBusyInterval()
{ {
@@ -254,6 +265,8 @@ public class SiteAuditTelemetryActorTests : TestKit
$"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}"); $"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}");
} }
/// <summary>Verifies that only the event ids the central ack accepted are marked as forwarded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted() public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted()
{ {
@@ -292,7 +305,7 @@ public class SiteAuditTelemetryActorTests : TestKit
} }
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
// AuditLog-001: combined-telemetry cached-drain regression tests. Verify // Combined-telemetry cached-drain regression tests. Verify
// that the production wiring of the previously-unreachable cached transport // that the production wiring of the previously-unreachable cached transport
// routes cached rows through ReadPendingCachedTelemetryAsync + // routes cached rows through ReadPendingCachedTelemetryAsync +
// IngestCachedTelemetryAsync (and NOT IngestAuditEventsAsync), and that // IngestCachedTelemetryAsync (and NOT IngestAuditEventsAsync), and that
@@ -305,7 +318,7 @@ public class SiteAuditTelemetryActorTests : TestKit
Guid? eventId = null, Guid? eventId = null,
Guid? correlationId = null, Guid? correlationId = null,
string sourceSiteId = "site-1") => string sourceSiteId = "site-1") =>
// C3 (Task 2.5): canonical record via the shared factory; ForwardState is // Canonical record via the shared factory; ForwardState is
// no longer a record field (the SQLite shim defaults it on INSERT). // no longer a record field (the SQLite shim defaults it on INSERT).
ScadaBridgeAuditEventFactory.Create( ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound, channel: AuditChannel.ApiOutbound,
@@ -335,12 +348,14 @@ public class SiteAuditTelemetryActorTests : TestKit
SourceScript: "script-1", SourceScript: "script-1",
SourceNode: "node-a"); SourceNode: "node-a");
/// <summary>Verifies that cached rows are sent through the cached-telemetry RPC rather than the audit-events RPC.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CachedDrain_CachedRows_RouteThrough_IngestCachedTelemetry_NotIngestAuditEvents() public async Task CachedDrain_CachedRows_RouteThrough_IngestCachedTelemetry_NotIngestAuditEvents()
{ {
// Arrange — three cached audit rows on the cached queue, each with a // Arrange — three cached audit rows on the cached queue, each with a
// matching tracking snapshot. The audit-only queue is empty (those // matching tracking snapshot. The audit-only queue is empty (those
// rows are excluded by ReadPendingAsync after AuditLog-001). // rows are excluded by ReadPendingAsync after the combined-telemetry change).
var cachedRows = new[] var cachedRows = new[]
{ {
NewCachedEvent(AuditKind.CachedSubmit), NewCachedEvent(AuditKind.CachedSubmit),
@@ -401,6 +416,8 @@ public class SiteAuditTelemetryActorTests : TestKit
Assert.Equal(expectedIds, emittedEventIds); Assert.Equal(expectedIds, emittedEventIds);
} }
/// <summary>Verifies that a cached row with no tracking snapshot past the grace period is abandoned while a valid row still flows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CachedDrain_OrphanRow_PastGrace_IsAbandoned_AndTheValidRowStillFlows() public async Task CachedDrain_OrphanRow_PastGrace_IsAbandoned_AndTheValidRowStillFlows()
{ {
@@ -494,6 +511,8 @@ public class SiteAuditTelemetryActorTests : TestKit
Assert.Equal(valid.EventId.ToString(), capturedBatch.Packets[0].AuditEvent.EventId); Assert.Equal(valid.EventId.ToString(), capturedBatch.Packets[0].AuditEvent.EventId);
} }
/// <summary>Verifies that a cached row with no tracking snapshot inside the grace period is retried rather than abandoned.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CachedDrain_OrphanRow_InsideGrace_IsRetried_NotAbandoned() public async Task CachedDrain_OrphanRow_InsideGrace_IsRetried_NotAbandoned()
{ {
@@ -536,6 +555,8 @@ public class SiteAuditTelemetryActorTests : TestKit
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>()); Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that a full batch of unresolvable cached rows does not starve the rest of the queue.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue() public async Task CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue()
{ {
@@ -575,6 +596,8 @@ public class SiteAuditTelemetryActorTests : TestKit
Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>()); Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that a tracking-store failure does not cause the cached row to be abandoned.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CachedDrain_TrackingStoreThrow_DoesNotAbandonTheRow() public async Task CachedDrain_TrackingStoreThrow_DoesNotAbandonTheRow()
{ {
@@ -608,13 +631,15 @@ public class SiteAuditTelemetryActorTests : TestKit
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>()); Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
} }
/// <summary>Verifies that audit-only rows still drain when the cached drain is disabled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuditOnlyDrain_StillFlows_When_CachedDrain_IsDisabled() public async Task AuditOnlyDrain_StillFlows_When_CachedDrain_IsDisabled()
{ {
// Arrange — ordinary (non-cached) audit rows on the audit-only queue; // Arrange — ordinary (non-cached) audit rows on the audit-only queue;
// the actor is constructed WITHOUT a tracking store so the cached // the actor is constructed WITHOUT a tracking store so the cached
// scheduler is never armed. Regression guard against the audit-only // scheduler is never armed. Regression guard against the audit-only
// drain regressing during the AuditLog-001 refactor. // drain regressing during the combined-telemetry refactor.
var rows = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList(); var rows = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()) _queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns( .Returns(
@@ -655,6 +680,7 @@ public class SiteAuditTelemetryActorTests : TestKit
/// test in this class masks the bug by returning already-completed tasks /// test in this class masks the bug by returning already-completed tasks
/// from the mocks, which keeps the continuations on the actor thread. /// from the mocks, which keeps the continuations on the actor thread.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Drains_Whose_Awaits_Complete_Off_The_Actor_Thread_Keep_Draining_Without_Crashing() public async Task Drains_Whose_Awaits_Complete_Off_The_Actor_Thread_Keep_Draining_Without_Crashing()
{ {
@@ -61,6 +61,8 @@ public class SiteCommunicationAuditClientTests : TestKit
UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)), UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)),
}; };
/// <summary>Verifies that a full acknowledgement maps all accepted event ids onto the returned ack.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck() public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck()
{ {
@@ -88,6 +90,8 @@ public class SiteCommunicationAuditClientTests : TestKit
ack.AcceptedEventIds.ToHashSet()); ack.AcceptedEventIds.ToHashSet());
} }
/// <summary>Verifies that a partial acknowledgement lists only the accepted event ids on the returned ack.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck() public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck()
{ {
@@ -110,6 +114,8 @@ public class SiteCommunicationAuditClientTests : TestKit
ack.AcceptedEventIds.ToHashSet()); ack.AcceptedEventIds.ToHashSet());
} }
/// <summary>Verifies that an Ask timeout throws so the drain loop keeps its rows pending.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending() public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending()
{ {
@@ -127,6 +133,8 @@ public class SiteCommunicationAuditClientTests : TestKit
await Assert.ThrowsAnyAsync<Exception>(() => task); await Assert.ThrowsAnyAsync<Exception>(() => task);
} }
/// <summary>Verifies that a faulted reply causes the ingest call to throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestAuditEventsAsync_FaultedReply_Throws() public async Task IngestAuditEventsAsync_FaultedReply_Throws()
{ {
@@ -136,13 +144,15 @@ public class SiteCommunicationAuditClientTests : TestKit
var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None); var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None);
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3)); probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
// A Status.Failure from central (Task 1: central does not swallow an // A Status.Failure from central (central does not swallow an
// ingest fault into an empty ack) must propagate as a thrown exception. // ingest fault into an empty ack) must propagate as a thrown exception.
probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted"))); probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted")));
await Assert.ThrowsAnyAsync<Exception>(() => task); await Assert.ThrowsAnyAsync<Exception>(() => task);
} }
/// <summary>Verifies that cached telemetry is routed as a command and the reply is mapped back.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply() public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply()
{ {
@@ -178,6 +188,8 @@ public class SiteCommunicationAuditClientTests : TestKit
ack.AcceptedEventIds.ToHashSet()); ack.AcceptedEventIds.ToHashSet());
} }
/// <summary>Verifies that an Ask timeout during cached telemetry ingestion throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task IngestCachedTelemetryAsync_AskTimeout_Throws() public async Task IngestCachedTelemetryAsync_AskTimeout_Throws()
{ {
@@ -11,8 +11,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
/// </summary> /// </summary>
internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider
{ {
// Returns the configured node name, which may be null.
/// <inheritdoc />
public string? NodeName { get; } public string? NodeName { get; }
/// <summary>Initializes the fake with the node name to report.</summary>
/// <param name="nodeName">The node name to report, or null for none.</param>
public FakeNodeIdentityProvider(string? nodeName = null) public FakeNodeIdentityProvider(string? nodeName = null)
{ {
NodeName = nodeName; NodeName = nodeName;
@@ -30,6 +30,7 @@ internal static class StubAuditRepositoryProvider
/// Builds a provider whose every scope yields the same audit-log repository instance. /// Builds a provider whose every scope yields the same audit-log repository instance.
/// </summary> /// </summary>
/// <param name="repository">Repository instance shared by every scope.</param> /// <param name="repository">Repository instance shared by every scope.</param>
/// <returns>A service provider whose scopes all resolve the supplied repository.</returns>
public static ServiceProvider Build(IAuditLogRepository repository) public static ServiceProvider Build(IAuditLogRepository repository)
{ {
ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(repository);
@@ -41,6 +41,7 @@ public class AuditConfigTableProjectionTests
totalCount), totalCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
/// <summary>Verifies that projecting entries unwraps the envelope into rows.</summary>
[Fact] [Fact]
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows() public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
{ {
@@ -68,6 +69,7 @@ public class AuditConfigTableProjectionTests
Assert.Equal("MES", second.GetProperty("entityName").GetString()); Assert.Equal("MES", second.GetProperty("entityName").GetString());
} }
/// <summary>Verifies that projecting entries drops the after-state JSON.</summary>
[Fact] [Fact]
public void ProjectEntries_DropsAfterStateJson() public void ProjectEntries_DropsAfterStateJson()
{ {
@@ -80,6 +82,7 @@ public class AuditConfigTableProjectionTests
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>Verifies that a truncated page appends a note with the total count.</summary>
[Fact] [Fact]
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote() public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
{ {
@@ -96,6 +99,7 @@ public class AuditConfigTableProjectionTests
Assert.False(root[2].TryGetProperty("id", out _)); Assert.False(root[2].TryGetProperty("id", out _));
} }
/// <summary>Verifies that a result set returned in full emits no note column.</summary>
[Fact] [Fact]
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn() public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
{ {
@@ -108,6 +112,7 @@ public class AuditConfigTableProjectionTests
Assert.DoesNotContain("note", compact, StringComparison.Ordinal); Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
} }
/// <summary>Verifies that an empty result stays an empty array.</summary>
[Fact] [Fact]
public void ProjectEntries_EmptyResult_StaysAnEmptyArray() public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
{ {
@@ -124,6 +129,7 @@ public class AuditConfigTableProjectionTests
Assert.Equal(0, doc.RootElement.GetArrayLength()); Assert.Equal(0, doc.RootElement.GetArrayLength());
} }
/// <summary>Verifies that a bare array is still projected.</summary>
[Fact] [Fact]
public void ProjectEntries_BareArray_IsStillProjected() public void ProjectEntries_BareArray_IsStillProjected()
{ {
@@ -143,6 +149,7 @@ public class AuditConfigTableProjectionTests
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _)); Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
} }
/// <summary>Verifies that an unrecognised JSON object is returned verbatim.</summary>
[Fact] [Fact]
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim() public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
{ {
@@ -152,6 +159,7 @@ public class AuditConfigTableProjectionTests
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other)); Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
} }
/// <summary>Verifies that non-JSON input is returned verbatim.</summary>
[Fact] [Fact]
public void ProjectEntries_NonJson_ReturnedVerbatim() public void ProjectEntries_NonJson_ReturnedVerbatim()
{ {
@@ -159,6 +167,7 @@ public class AuditConfigTableProjectionTests
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson)); Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
} }
/// <summary>Verifies that the projection is substantially smaller than the raw payload.</summary>
[Fact] [Fact]
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload() public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
{ {
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Environment")] [Collection("Environment")]
public class CliConfigTests public class CliConfigTests
{ {
/// <summary>Verifies that loading configuration with no overrides defaults the output format to JSON.</summary>
[Fact] [Fact]
public void Load_DefaultFormat_IsJson() public void Load_DefaultFormat_IsJson()
{ {
@@ -28,6 +29,7 @@ public class CliConfigTests
} }
} }
/// <summary>Verifies that the management URL is read from the environment.</summary>
[Fact] [Fact]
public void Load_ManagementUrl_FromEnvironment() public void Load_ManagementUrl_FromEnvironment()
{ {
@@ -46,6 +48,7 @@ public class CliConfigTests
} }
} }
/// <summary>Verifies that the output format is read from the environment.</summary>
[Fact] [Fact]
public void Load_Format_FromEnvironment() public void Load_Format_FromEnvironment()
{ {
@@ -65,7 +68,7 @@ public class CliConfigTests
} }
/// <summary> /// <summary>
/// CLI-021 regression: a malformed ~/.scadabridge/config.json must NOT abort the /// Regression: a malformed ~/.scadabridge/config.json must NOT abort the
/// CLI before any command runs — Load() must warn (to stderr) and return a /// CLI before any command runs — Load() must warn (to stderr) and return a
/// usable default config so command-line overrides (--url, --username, etc.) /// usable default config so command-line overrides (--url, --username, etc.)
/// and env vars can still take effect. /// and env vars can still take effect.
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")] [Collection("Console")]
public class CommandHelpersTests public class CommandHelpersTests
{ {
/// <summary>Verifies that a successful response in JSON format returns exit code zero.</summary>
[Fact] [Fact]
public void HandleResponse_Success_JsonFormat_ReturnsZero() public void HandleResponse_Success_JsonFormat_ReturnsZero()
{ {
@@ -21,6 +22,7 @@ public class CommandHelpersTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
} }
/// <summary>Verifies that a successful response with array data in table format returns exit code zero.</summary>
[Fact] [Fact]
public void HandleResponse_Success_TableFormat_ArrayData_ReturnsZero() public void HandleResponse_Success_TableFormat_ArrayData_ReturnsZero()
{ {
@@ -41,6 +43,7 @@ public class CommandHelpersTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
} }
/// <summary>Verifies that a successful response with object data in table format returns exit code zero.</summary>
[Fact] [Fact]
public void HandleResponse_Success_TableFormat_ObjectData_ReturnsZero() public void HandleResponse_Success_TableFormat_ObjectData_ReturnsZero()
{ {
@@ -61,6 +64,7 @@ public class CommandHelpersTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
} }
/// <summary>Verifies that an empty array in table format reports that there are no results.</summary>
[Fact] [Fact]
public void HandleResponse_Success_TableFormat_EmptyArray_ShowsNoResults() public void HandleResponse_Success_TableFormat_EmptyArray_ShowsNoResults()
{ {
@@ -76,6 +80,7 @@ public class CommandHelpersTests
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
} }
/// <summary>Verifies that an error response returns exit code one.</summary>
[Fact] [Fact]
public void HandleResponse_Error_ReturnsOne() public void HandleResponse_Error_ReturnsOne()
{ {
@@ -91,6 +96,7 @@ public class CommandHelpersTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
} }
/// <summary>Verifies that an unauthorized response returns exit code two.</summary>
[Fact] [Fact]
public void HandleResponse_Unauthorized_ReturnsTwo() public void HandleResponse_Unauthorized_ReturnsTwo()
{ {
@@ -106,6 +112,7 @@ public class CommandHelpersTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
} }
/// <summary>Verifies that an authentication failure response returns exit code one.</summary>
[Fact] [Fact]
public void HandleResponse_AuthFailure_ReturnsOne() public void HandleResponse_AuthFailure_ReturnsOne()
{ {
@@ -121,6 +128,7 @@ public class CommandHelpersTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
} }
/// <summary>Verifies that a connection failure response returns exit code one.</summary>
[Fact] [Fact]
public void HandleResponse_ConnectionFailure_ReturnsOne() public void HandleResponse_ConnectionFailure_ReturnsOne()
{ {
@@ -136,6 +144,7 @@ public class CommandHelpersTests
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
} }
/// <summary>Verifies that a timeout response returns exit code one.</summary>
[Fact] [Fact]
public void HandleResponse_Timeout_ReturnsOne() public void HandleResponse_Timeout_ReturnsOne()
{ {
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary> /// <summary>
/// Regression tests for CLI-013 — the command-tree wiring was untested. These tests /// Regression tests for the command-tree wiring, which was previously untested. These tests
/// build every command group and assert the tree is well-formed (every leaf has an /// build every command group and assert the tree is well-formed (every leaf has an
/// action, no group is empty), and that every management command record the CLI sends /// action, no group is empty), and that every management command record the CLI sends
/// resolves via <see cref="ManagementCommandRegistry"/> (so command-name derivation /// resolves via <see cref="ManagementCommandRegistry"/> (so command-name derivation
@@ -21,6 +21,7 @@ public class CommandTreeTests
// Exercise the production root: a separately maintained group list can drift. // Exercise the production root: a separately maintained group list can drift.
private static IEnumerable<Command> AllCommandGroups() => CliApplication.BuildRoot().Subcommands; private static IEnumerable<Command> AllCommandGroups() => CliApplication.BuildRoot().Subcommands;
/// <summary>Verifies that the registered operational command groups include the secured-writes and connection tools.</summary>
[Fact] [Fact]
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools() public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
{ {
@@ -47,6 +48,7 @@ public class CommandTreeTests
yield return leaf; yield return leaf;
} }
/// <summary>Verifies that every command group builds without throwing.</summary>
[Fact] [Fact]
public void AllCommandGroups_Build_WithoutThrowing() public void AllCommandGroups_Build_WithoutThrowing()
{ {
@@ -55,10 +57,11 @@ public class CommandTreeTests
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name))); Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
} }
/// <summary>Verifies that the set of command groups contains the audit and bundle groups.</summary>
[Fact] [Fact]
public void AllCommandGroups_Contains_AuditAndBundle() public void AllCommandGroups_Contains_AuditAndBundle()
{ {
// CLI-022: explicit group-presence assertion so the harness does not // Explicit group-presence assertion so the harness does not
// silently drift back to excluding new groups. Use names because that // silently drift back to excluding new groups. Use names because that
// is what users actually type at the prompt. // is what users actually type at the prompt.
var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet(); var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet();
@@ -66,10 +69,11 @@ public class CommandTreeTests
Assert.Contains("bundle", groupNames); Assert.Contains("bundle", groupNames);
} }
/// <summary>Verifies that the audit command group exposes the query, export and verify-chain subcommands.</summary>
[Fact] [Fact]
public void AuditCommandGroup_HasQueryExportAndVerifyChain() public void AuditCommandGroup_HasQueryExportAndVerifyChain()
{ {
// CLI-022: pin the audit sub-command surface so a rename / accidental // Pin the audit sub-command surface so a rename / accidental
// removal of one of these is caught. // removal of one of these is caught.
var audit = AuditCommands.Build(Url, Format, Username, Password); var audit = AuditCommands.Build(Url, Format, Username, Password);
var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet(); var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet();
@@ -78,10 +82,11 @@ public class CommandTreeTests
Assert.Contains("verify-chain", subNames); Assert.Contains("verify-chain", subNames);
} }
/// <summary>Verifies that the bundle command group exposes the export, preview and import subcommands.</summary>
[Fact] [Fact]
public void BundleCommandGroup_HasExportPreviewAndImport() public void BundleCommandGroup_HasExportPreviewAndImport()
{ {
// CLI-022: pin the bundle sub-command surface. // Pin the bundle sub-command surface.
var bundle = BundleCommands.Build(Url, Format, Username, Password); var bundle = BundleCommands.Build(Url, Format, Username, Password);
var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet(); var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("export", subNames); Assert.Contains("export", subNames);
@@ -89,6 +94,7 @@ public class CommandTreeTests
Assert.Contains("import", subNames); Assert.Contains("import", subNames);
} }
/// <summary>Verifies that every leaf command in the tree has an action attached.</summary>
[Fact] [Fact]
public void EveryLeafCommand_HasAnAction() public void EveryLeafCommand_HasAnAction()
{ {
@@ -100,10 +106,11 @@ public class CommandTreeTests
Assert.True(leaf.Action != null, $"Leaf command '{leaf.Name}' has no action.")); Assert.True(leaf.Action != null, $"Leaf command '{leaf.Name}' has no action."));
} }
/// <summary>Verifies that template composition delete is keyed by the composition id alone.</summary>
[Fact] [Fact]
public void TemplateCompositionDelete_IsKeyedByIdOnly() public void TemplateCompositionDelete_IsKeyedByIdOnly()
{ {
// CLI-015: the in-repo README documented `template composition delete` with // The in-repo README documented `template composition delete` with
// --template-id / --instance-name, but the implementation keys deletion by the // --template-id / --instance-name, but the implementation keys deletion by the
// composition's own integer ID via a single --id option. Pin the real surface. // composition's own integer ID via a single --id option. Pin the real surface.
var template = TemplateCommands.Build(Url, Format, Username, Password); var template = TemplateCommands.Build(Url, Format, Username, Password);
@@ -116,6 +123,7 @@ public class CommandTreeTests
Assert.DoesNotContain("--instance-name", optionNames); Assert.DoesNotContain("--instance-name", optionNames);
} }
/// <summary>Verifies that the template native alarm source group has add, list and remove subcommands.</summary>
[Fact] [Fact]
public void TemplateNativeAlarmSource_HasAddListRemove() public void TemplateNativeAlarmSource_HasAddListRemove()
{ {
@@ -127,10 +135,11 @@ public class CommandTreeTests
Assert.Contains("remove", subNames); Assert.Contains("remove", subNames);
} }
/// <summary>Verifies that the deploy group has a site verb that requires a site id.</summary>
[Fact] [Fact]
public void Deploy_HasSiteVerb_WithRequiredSiteId() public void Deploy_HasSiteVerb_WithRequiredSiteId()
{ {
// WP2.5: bulk site deploy. Named `deploy site` to sit alongside // Bulk site deploy. Named `deploy site` to sit alongside
// `deploy instance` / `deploy artifacts` — the group's convention is that // `deploy instance` / `deploy artifacts` — the group's convention is that
// the verb names the SCOPE of the deploy. // the verb names the SCOPE of the deploy.
var deploy = DeployCommands.Build(Url, Format, Username, Password); var deploy = DeployCommands.Build(Url, Format, Username, Password);
@@ -143,10 +152,11 @@ public class CommandTreeTests
Assert.Empty(site.Parse(["--site-id", "3"]).Errors); Assert.Empty(site.Parse(["--site-id", "3"]).Errors);
} }
/// <summary>Verifies that deploy site has a force flag that defaults to the non-destructive behaviour.</summary>
[Fact] [Fact]
public void DeploySite_HasForceFlag_DefaultingToTheNonDestructiveBehaviour() public void DeploySite_HasForceFlag_DefaultingToTheNonDestructiveBehaviour()
{ {
// P7-6: bulk deploy skips instances whose config is unchanged, because a // Bulk deploy skips instances whose config is unchanged, because a
// redeploy resets their static overrides and native alarm state at the // redeploy resets their static overrides and native alarm state at the
// site. --force is the operator escape hatch (restored volume, rebuilt // site. --force is the operator escape hatch (restored volume, rebuilt
// node) and must be a FLAG — absent means skip-unchanged, which is the // node) and must be a FLAG — absent means skip-unchanged, which is the
@@ -165,6 +175,7 @@ public class CommandTreeTests
Assert.True(withForce.GetValue(forceOption)); Assert.True(withForce.GetValue(forceOption));
} }
/// <summary>Verifies that the instance native alarm source group has set and clear subcommands.</summary>
[Fact] [Fact]
public void InstanceNativeAlarmSource_HasSetAndClear() public void InstanceNativeAlarmSource_HasSetAndClear()
{ {
@@ -175,6 +186,7 @@ public class CommandTreeTests
Assert.Contains("clear", subNames); Assert.Contains("clear", subNames);
} }
/// <summary>Verifies that the template folder group covers every folder management command.</summary>
[Fact] [Fact]
public void TemplateFolder_HasFullManagementCommandParity() public void TemplateFolder_HasFullManagementCommandParity()
{ {
@@ -192,6 +204,7 @@ public class CommandTreeTests
Assert.Contains("delete", subNames); Assert.Contains("delete", subNames);
} }
/// <summary>Verifies that the parent id is optional on template folder create so the root folder is reachable.</summary>
[Fact] [Fact]
public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable() public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable()
{ {
@@ -211,6 +224,7 @@ public class CommandTreeTests
Assert.Equal(7, nestedParse.GetValue((Option<int?>)parentOption)); Assert.Equal(7, nestedParse.GetValue((Option<int?>)parentOption));
} }
/// <summary>Verifies that the parent id is optional on template folder move so the root folder is reachable.</summary>
[Fact] [Fact]
public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable() public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable()
{ {
@@ -224,6 +238,8 @@ public class CommandTreeTests
Assert.Null(parse.GetValue((Option<int?>)parentOption)); Assert.Null(parse.GetValue((Option<int?>)parentOption));
} }
/// <summary>Verifies that template folder reorder accepts the up and down directions.</summary>
/// <param name="direction">The reorder direction under test.</param>
[Theory] [Theory]
[InlineData("up")] [InlineData("up")]
[InlineData("down")] [InlineData("down")]
@@ -237,6 +253,8 @@ public class CommandTreeTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the folder reorder command rejects any direction other than the accepted literals.</summary>
/// <param name="direction">The rejected direction value.</param>
[Theory] [Theory]
[InlineData("sideways")] [InlineData("sideways")]
// AcceptOnlyFromAmong is case-SENSITIVE, so the literals are lowercase only. // AcceptOnlyFromAmong is case-SENSITIVE, so the literals are lowercase only.
@@ -245,6 +263,8 @@ public class CommandTreeTests
// case-sensitivity rejected values the server accepts. --direction keeps it: it is // case-sensitivity rejected values the server accepts. --direction keeps it: it is
// a pure client-side literal that never reaches a server-side parse, so there is no // a pure client-side literal that never reaches a server-side parse, so there is no
// authoritative check behind it to defer to. // authoritative check behind it to defer to.
/// <summary>Verifies that template folder reorder rejects any direction other than up or down.</summary>
/// <param name="direction">The invalid reorder direction under test.</param>
[InlineData("Up")] [InlineData("Up")]
public void TemplateFolderReorder_RejectsAnythingElse(string direction) public void TemplateFolderReorder_RejectsAnythingElse(string direction)
{ {
@@ -256,6 +276,7 @@ public class CommandTreeTests
Assert.NotEmpty(parse.Errors); Assert.NotEmpty(parse.Errors);
} }
/// <summary>Verifies that template folder reorder requires a direction.</summary>
[Fact] [Fact]
public void TemplateFolderReorder_RequiresDirection() public void TemplateFolderReorder_RequiresDirection()
{ {
@@ -278,6 +299,8 @@ public class CommandTreeTests
// treatment row 88 gave `deploy status`). These tests fail before that change. // treatment row 88 gave `deploy status`). These tests fail before that change.
// ======================================================================== // ========================================================================
/// <summary>Provides the audit filter option names and sample values used by the theory data.</summary>
/// <returns>Theory data of subcommand, option and value triples.</returns>
public static TheoryData<string, string, string> AuditFilterValues() => new() public static TheoryData<string, string, string> AuditFilterValues() => new()
{ {
// subcommand, option, value — lowercase, uppercase and mixed case of a real member. // subcommand, option, value — lowercase, uppercase and mixed case of a real member.
@@ -290,6 +313,10 @@ public class CommandTreeTests
{ "export", "--kind", "dbwrite" }, { "export", "--kind", "dbwrite" },
}; };
/// <summary>Verifies that audit filter options accept values in any letter case.</summary>
/// <param name="subcommand">The audit subcommand under test.</param>
/// <param name="option">The filter option name.</param>
/// <param name="value">The option value whose casing varies.</param>
[Theory] [Theory]
[MemberData(nameof(AuditFilterValues))] [MemberData(nameof(AuditFilterValues))]
public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value) public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value)
@@ -308,6 +335,8 @@ public class CommandTreeTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the audit export format option accepts values in any letter case.</summary>
/// <param name="format">The export format value under test.</param>
[Theory] [Theory]
[InlineData("csv")] [InlineData("csv")]
[InlineData("CSV")] [InlineData("CSV")]
@@ -328,6 +357,7 @@ public class CommandTreeTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that an unrecognised audit query filter value is not rejected on the client side.</summary>
[Fact] [Fact]
public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide() public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide()
{ {
@@ -342,6 +372,7 @@ public class CommandTreeTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the site area group has a move verb.</summary>
[Fact] [Fact]
public void SiteArea_HasMoveVerb() public void SiteArea_HasMoveVerb()
{ {
@@ -353,6 +384,7 @@ public class CommandTreeTests
Assert.Contains("move", subNames); Assert.Contains("move", subNames);
} }
/// <summary>Verifies that the parent id is optional on site area move so the site root is reachable.</summary>
[Fact] [Fact]
public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable() public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable()
{ {
@@ -372,6 +404,7 @@ public class CommandTreeTests
Assert.Equal(2, reparentParse.GetValue((Option<int?>)parentOption)); Assert.Equal(2, reparentParse.GetValue((Option<int?>)parentOption));
} }
/// <summary>Verifies that site area move requires an area id.</summary>
[Fact] [Fact]
public void SiteAreaMove_RequiresId() public void SiteAreaMove_RequiresId()
{ {
@@ -383,6 +416,7 @@ public class CommandTreeTests
Assert.NotEmpty(parse.Errors); Assert.NotEmpty(parse.Errors);
} }
/// <summary>Verifies that template list has skip and take paging options.</summary>
[Fact] [Fact]
public void TemplateList_HasSkipAndTakePagingOptions() public void TemplateList_HasSkipAndTakePagingOptions()
{ {
@@ -394,6 +428,7 @@ public class CommandTreeTests
Assert.Contains("--take", optionNames); Assert.Contains("--take", optionNames);
} }
/// <summary>Verifies that instance list has skip and take paging options.</summary>
[Fact] [Fact]
public void InstanceList_HasSkipAndTakePagingOptions() public void InstanceList_HasSkipAndTakePagingOptions()
{ {
@@ -405,6 +440,7 @@ public class CommandTreeTests
Assert.Contains("--take", optionNames); Assert.Contains("--take", optionNames);
} }
/// <summary>Verifies that instance list parses the value of the take option.</summary>
[Fact] [Fact]
public void InstanceList_ParsesTakeOptionValue() public void InstanceList_ParsesTakeOptionValue()
{ {
@@ -418,6 +454,8 @@ public class CommandTreeTests
Assert.Equal(5, parse.GetValue((Option<int?>)takeOption)); Assert.Equal(5, parse.GetValue((Option<int?>)takeOption));
} }
/// <summary>Verifies that each command payload type resolves through the management command registry.</summary>
/// <param name="commandType">The command payload type under test.</param>
[Theory] [Theory]
[InlineData(typeof(GetInstanceCommand))] [InlineData(typeof(GetInstanceCommand))]
[InlineData(typeof(ListSitesCommand))] [InlineData(typeof(ListSitesCommand))]
@@ -8,9 +8,8 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand /// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand:
/// (Audit Log #23 M5.6 T5): argument parsing, request-body construction, /// argument parsing, request-body construction, HTTP wiring, and CLI scaffold.
/// HTTP wiring, and CLI scaffold.
/// </summary> /// </summary>
[Collection("Console")] [Collection("Console")]
public class AuditBackfillCommandTests public class AuditBackfillCommandTests
@@ -19,6 +18,7 @@ public class AuditBackfillCommandTests
// BuildRequestBody // BuildRequestBody
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that building a request body from default arguments contains the expected sentinel, before and batch size fields.</summary>
[Fact] [Fact]
public void BuildRequestBody_DefaultArgs_ContainsExpectedFields() public void BuildRequestBody_DefaultArgs_ContainsExpectedFields()
{ {
@@ -38,6 +38,7 @@ public class AuditBackfillCommandTests
Assert.Equal(5000, root.GetProperty("batchSize").GetInt32()); Assert.Equal(5000, root.GetProperty("batchSize").GetInt32());
} }
/// <summary>Verifies that a custom sentinel and batch size are reflected in the request body JSON.</summary>
[Fact] [Fact]
public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson() public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson()
{ {
@@ -66,16 +67,23 @@ public class AuditBackfillCommandTests
private readonly HttpStatusCode _status; private readonly HttpStatusCode _status;
private readonly string _responseBody; private readonly string _responseBody;
/// <summary>Initializes a new handler that returns the given status and body for every request.</summary>
/// <param name="status">The HTTP status code to return.</param>
/// <param name="responseBody">The response body to return.</param>
public CapturingHandler(HttpStatusCode status, string responseBody) public CapturingHandler(HttpStatusCode status, string responseBody)
{ {
_status = status; _status = status;
_responseBody = responseBody; _responseBody = responseBody;
} }
/// <summary>Gets the path and query of the last request received.</summary>
public string? LastRequestUri { get; private set; } public string? LastRequestUri { get; private set; }
/// <summary>Gets the body of the last request received.</summary>
public string? LastRequestBody { get; private set; } public string? LastRequestBody { get; private set; }
/// <summary>Gets the HTTP method of the last request received.</summary>
public string? LastMethod { get; private set; } public string? LastMethod { get; private set; }
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync( protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
@@ -95,6 +103,8 @@ public class AuditBackfillCommandTests
private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z") private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z")
=> JsonSerializer.Serialize(new { rowsUpdated, sentinel, before }); => JsonSerializer.Serialize(new { rowsUpdated, sentinel, before });
/// <summary>Verifies that a successful backfill returns exit code zero and writes the response to the output.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput() public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput()
{ {
@@ -117,6 +127,8 @@ public class AuditBackfillCommandTests
Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase); Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>Verifies that the backfill request is sent to the backfill endpoint path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunBackfill_RequestUri_ContainsBackfillPath() public async Task RunBackfill_RequestUri_ContainsBackfillPath()
{ {
@@ -133,6 +145,8 @@ public class AuditBackfillCommandTests
Assert.Equal("POST", handler.LastMethod); Assert.Equal("POST", handler.LastMethod);
} }
/// <summary>Verifies that the backfill request body contains the sentinel and the before timestamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore() public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore()
{ {
@@ -157,6 +171,8 @@ public class AuditBackfillCommandTests
Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32()); Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32());
} }
/// <summary>Verifies that an HTTP 403 response yields exit code 2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunBackfill_Http403_ReturnsExitCode2() public async Task RunBackfill_Http403_ReturnsExitCode2()
{ {
@@ -173,6 +189,8 @@ public class AuditBackfillCommandTests
Assert.Equal(2, exit); Assert.Equal(2, exit);
} }
/// <summary>Verifies that an HTTP 500 response yields exit code 1.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunBackfill_Http500_ReturnsExitCode1() public async Task RunBackfill_Http500_ReturnsExitCode1()
{ {
@@ -193,6 +211,7 @@ public class AuditBackfillCommandTests
// CLI parsing // CLI parsing
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the backfill-source-node subcommand exists in the audit command group.</summary>
[Fact] [Fact]
public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup() public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup()
{ {
@@ -201,6 +220,7 @@ public class AuditBackfillCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the before option is required on the backfill-source-node subcommand.</summary>
[Fact] [Fact]
public void BackfillSourceNode_BeforeOption_IsRequired() public void BackfillSourceNode_BeforeOption_IsRequired()
{ {
@@ -209,6 +229,7 @@ public class AuditBackfillCommandTests
Assert.NotEqual(0, exit); Assert.NotEqual(0, exit);
} }
/// <summary>Verifies that the help text describes the sentinel and before options.</summary>
[Fact] [Fact]
public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore() public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore()
{ {
@@ -223,6 +244,7 @@ public class AuditBackfillCommandTests
Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase); Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>Verifies that the default sentinel value is unknown.</summary>
[Fact] [Fact]
public void BackfillSourceNode_DefaultSentinel_IsUnknown() public void BackfillSourceNode_DefaultSentinel_IsUnknown()
{ {
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// </summary> /// </summary>
internal static class AuditCommandTestHarness internal static class AuditCommandTestHarness
{ {
/// <summary>Builds a root command with the global options and the audit command tree attached.</summary>
/// <returns>The configured root command.</returns>
public static RootCommand BuildRoot() public static RootCommand BuildRoot()
{ {
var url = new Option<string>("--url") { Recursive = true }; var url = new Option<string>("--url") { Recursive = true };
@@ -33,6 +35,9 @@ internal static class AuditCommandTestHarness
/// the returned <c>Out</c>/<c>Err</c> strings. Callers must be in the <c>Console</c> /// the returned <c>Out</c>/<c>Err</c> strings. Callers must be in the <c>Console</c>
/// xUnit collection so the global <see cref="Console"/> redirect is not racy. /// xUnit collection so the global <see cref="Console"/> redirect is not racy.
/// </summary> /// </summary>
/// <param name="root">The root command to parse against.</param>
/// <param name="args">The command-line arguments to parse and invoke.</param>
/// <returns>The exit code and the captured standard output and error text.</returns>
public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args) public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args)
{ {
var output = new StringWriter(); var output = new StringWriter();
@@ -4,9 +4,9 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log #23 M8-T1). /// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log).
/// Verifies the parent command exists with its subcommands and that every leaf /// Verifies the parent command exists with its subcommands and that every leaf
/// has an action wired. Updated for M5.6 T5 to cover <c>backfill-source-node</c>. /// has an action wired. Also covers <c>backfill-source-node</c>.
/// </summary> /// </summary>
public class AuditCommandsScaffoldTests public class AuditCommandsScaffoldTests
{ {
@@ -18,6 +18,7 @@ public class AuditCommandsScaffoldTests
private static Command BuildAudit() private static Command BuildAudit()
=> AuditCommands.Build(Url, Format, Username, Password); => AuditCommands.Build(Url, Format, Username, Password);
/// <summary>Verifies that the command group is named <c>audit</c>.</summary>
[Fact] [Fact]
public void Audit_Command_IsNamedAudit() public void Audit_Command_IsNamedAudit()
{ {
@@ -26,6 +27,7 @@ public class AuditCommandsScaffoldTests
Assert.False(string.IsNullOrWhiteSpace(audit.Description)); Assert.False(string.IsNullOrWhiteSpace(audit.Description));
} }
/// <summary>Verifies that the audit group exposes the query, export, tree, verify-chain and backfill-source-node subcommands.</summary>
[Fact] [Fact]
public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode() public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode()
{ {
@@ -36,6 +38,7 @@ public class AuditCommandsScaffoldTests
names); names);
} }
/// <summary>Verifies that the audit help text lists every subcommand.</summary>
[Fact] [Fact]
public void Audit_HelpText_ListsAllSubcommands() public void Audit_HelpText_ListsAllSubcommands()
{ {
@@ -55,6 +58,7 @@ public class AuditCommandsScaffoldTests
Assert.Contains("backfill-source-node", text); Assert.Contains("backfill-source-node", text);
} }
/// <summary>Verifies that every leaf command in the audit group has an action wired.</summary>
[Fact] [Fact]
public void Audit_EveryLeafCommand_HasAnAction() public void Audit_EveryLeafCommand_HasAnAction()
{ {
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// Tests for the <c>audit-log</c> → <c>audit-config</c> rename (Audit Log #23 M8-T7): /// Tests for the <c>audit-log</c> → <c>audit-config</c> rename:
/// the new name parses, the deprecated <c>audit-log</c> alias still resolves the full /// the new name parses, the deprecated <c>audit-log</c> alias still resolves the full
/// subcommand tree and emits a stderr deprecation warning, and the renamed group does /// subcommand tree and emits a stderr deprecation warning, and the renamed group does
/// not collide with the distinct <c>audit</c> group from Bundle A. /// not collide with the distinct <c>audit</c> group from Bundle A.
@@ -28,6 +28,7 @@ public class AuditConfigDeprecationTests
return root; return root;
} }
/// <summary>Verifies that the audit-config query command works.</summary>
[Fact] [Fact]
public void AuditConfig_Query_Works() public void AuditConfig_Query_Works()
{ {
@@ -37,6 +38,7 @@ public class AuditConfigDeprecationTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the legacy audit-log query command still works but emits a deprecation warning to stderr.</summary>
[Fact] [Fact]
public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr() public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr()
{ {
@@ -54,6 +56,7 @@ public class AuditConfigDeprecationTests
Assert.Contains("audit-config", warning); Assert.Contains("audit-config", warning);
} }
/// <summary>Verifies that no deprecation warning is emitted when the new command name is used.</summary>
[Fact] [Fact]
public void DeprecationWarning_NotEmitted_ForNewName() public void DeprecationWarning_NotEmitted_ForNewName()
{ {
@@ -64,6 +67,7 @@ public class AuditConfigDeprecationTests
Assert.Equal("", stderr.ToString()); Assert.Equal("", stderr.ToString());
} }
/// <summary>Verifies that no deprecation warning is emitted for an unrelated command.</summary>
[Fact] [Fact]
public void DeprecationWarning_NotEmitted_ForUnrelatedCommand() public void DeprecationWarning_NotEmitted_ForUnrelatedCommand()
{ {
@@ -73,6 +77,7 @@ public class AuditConfigDeprecationTests
Assert.Equal("", stderr.ToString()); Assert.Equal("", stderr.ToString());
} }
/// <summary>Verifies that the audit and audit-config groups are distinct commands that do not conflict.</summary>
[Fact] [Fact]
public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict() public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict()
{ {
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log #23 M8-T3): /// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log):
/// required-flag enforcement, query-string construction, streaming the response body /// required-flag enforcement, query-string construction, streaming the response body
/// to the output file, and the parquet-not-implemented (501) path. /// to the output file, and the parquet-not-implemented (501) path.
/// </summary> /// </summary>
@@ -17,6 +17,7 @@ public class AuditExportCommandTests
{ {
// ---- CLI parsing: required flags -------------------------------------- // ---- CLI parsing: required flags --------------------------------------
/// <summary>Verifies that omitting a required flag produces a parse error and a non-zero exit code.</summary>
[Fact] [Fact]
public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit() public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
{ {
@@ -28,6 +29,7 @@ public class AuditExportCommandTests
Assert.Contains("--output", err); Assert.Contains("--output", err);
} }
/// <summary>Verifies that the command parses without error when all required flags are present.</summary>
[Fact] [Fact]
public void Export_AllRequiredFlagsPresent_ParsesWithoutError() public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
{ {
@@ -40,6 +42,7 @@ public class AuditExportCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that an invalid format value is not rejected at the parse layer.</summary>
[Fact] [Fact]
public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer() public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer()
{ {
@@ -56,6 +59,8 @@ public class AuditExportCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the format value is accepted in any letter case.</summary>
/// <param name="format">The format value to parse, in varying case.</param>
[Theory] [Theory]
[InlineData("CSV")] [InlineData("CSV")]
[InlineData("Csv")] [InlineData("Csv")]
@@ -73,6 +78,7 @@ public class AuditExportCommandTests
// ---- Query string ----------------------------------------------------- // ---- Query string -----------------------------------------------------
/// <summary>Verifies that the query string includes the time window, the format and the filters.</summary>
[Fact] [Fact]
public void BuildQueryString_IncludesWindowFormatAndFilters() public void BuildQueryString_IncludesWindowFormatAndFilters()
{ {
@@ -96,6 +102,7 @@ public class AuditExportCommandTests
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
} }
/// <summary>Verifies that multi-value filters emit one query key per value.</summary>
[Fact] [Fact]
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue() public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
{ {
@@ -120,6 +127,7 @@ public class AuditExportCommandTests
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId")); Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
} }
/// <summary>Verifies that multi-value filters that were not set are omitted from the query string.</summary>
[Fact] [Fact]
public void BuildQueryString_OmitsUnsetMultiValueFilters() public void BuildQueryString_OmitsUnsetMultiValueFilters()
{ {
@@ -140,6 +148,7 @@ public class AuditExportCommandTests
Assert.Null(parsed["sourceSiteId"]); Assert.Null(parsed["sourceSiteId"]);
} }
/// <summary>Verifies that multiple channel values supplied in a single token are accepted.</summary>
[Fact] [Fact]
public void Export_MultipleChannelValues_SingleToken_AreAccepted() public void Export_MultipleChannelValues_SingleToken_AreAccepted()
{ {
@@ -154,6 +163,7 @@ public class AuditExportCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that multiple channel values supplied through a repeated flag are accepted.</summary>
[Fact] [Fact]
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted() public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
{ {
@@ -167,6 +177,7 @@ public class AuditExportCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that a multi-value channel option still parses when one name is unrecognised.</summary>
[Fact] [Fact]
public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses() public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses()
{ {
@@ -188,14 +199,19 @@ public class AuditExportCommandTests
{ {
private readonly HttpStatusCode _status; private readonly HttpStatusCode _status;
private readonly Func<HttpContent> _content; private readonly Func<HttpContent> _content;
/// <summary>Gets the path and query string of the last request the handler received.</summary>
public string? RequestPathAndQuery { get; private set; } public string? RequestPathAndQuery { get; private set; }
/// <summary>Initializes a handler that answers every request with the given status and content.</summary>
/// <param name="status">The HTTP status code to return.</param>
/// <param name="content">A factory that creates the response content.</param>
public BodyHandler(HttpStatusCode status, Func<HttpContent> content) public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
{ {
_status = status; _status = status;
_content = content; _content = content;
} }
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
@@ -204,6 +220,8 @@ public class AuditExportCommandTests
} }
} }
/// <summary>Verifies that a successful export streams the response body to the output file.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_Success_StreamsResponseToOutputFile() public async Task RunExport_Success_StreamsResponseToOutputFile()
{ {
@@ -234,11 +252,13 @@ public class AuditExportCommandTests
} }
} }
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_Http403_ReturnsExitCode2() public async Task RunExport_Http403_ReturnsExitCode2()
{ {
// CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the // An HTTP 403 on /api/audit/export must produce exit code 2 per the
// documented CLI contract — the legacy bare-1 return masked auth failures // documented CLI contract — the legacy plain exit code 1 masked auth failures
// as generic command failures. // as generic command failures.
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv"); var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
try try
@@ -262,6 +282,8 @@ public class AuditExportCommandTests
} }
} }
/// <summary>Verifies that an unauthorized error code on a response whose status is not 403 also returns exit code 2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2() public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{ {
@@ -286,6 +308,8 @@ public class AuditExportCommandTests
} }
} }
/// <summary>Verifies that a parquet 501 response prints the server message and returns a non-zero exit code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero() public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
{ {
@@ -312,6 +336,8 @@ public class AuditExportCommandTests
} }
} }
/// <summary>Verifies that a large response body is streamed to the file rather than fully buffered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered() public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
{ {
@@ -351,18 +377,27 @@ public class AuditExportCommandTests
private readonly byte _value; private readonly byte _value;
private long _remaining; private long _remaining;
/// <summary>Initializes a stream that yields the given byte repeatedly.</summary>
/// <param name="value">The byte value to repeat.</param>
/// <param name="length">The total number of bytes the stream yields.</param>
public RepeatingStream(byte value, long length) public RepeatingStream(byte value, long length)
{ {
_value = value; _value = value;
_remaining = length; _remaining = length;
} }
/// <inheritdoc />
public override bool CanRead => true; public override bool CanRead => true;
/// <inheritdoc />
public override bool CanSeek => false; public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false; public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => throw new NotSupportedException(); public override long Length => throw new NotSupportedException();
/// <inheritdoc />
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count) public override int Read(byte[] buffer, int offset, int count)
{ {
if (_remaining <= 0) return 0; if (_remaining <= 0) return 0;
@@ -372,14 +407,18 @@ public class AuditExportCommandTests
return n; return n;
} }
/// <inheritdoc />
public override void Flush() { } public override void Flush() { }
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
/// <inheritdoc />
public override void SetLength(long value) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
} }
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Deadline + Ctrl-C cancellation (arch-review 2026-08-19 P6-8: audit export // Deadline + Ctrl-C cancellation (audit export
// was the one CLI HTTP path with NO deadline — a hung server hung the CLI // was the one CLI HTTP path with NO deadline — a hung server hung the CLI
// forever). Every test below bounds its own wait well under what the old, // forever). Every test below bounds its own wait well under what the old,
// deadline-less implementation would have done (indefinite hang) — a // deadline-less implementation would have done (indefinite hang) — a
@@ -389,8 +428,10 @@ public class AuditExportCommandTests
/// <summary>A handler that never completes the request until its <see cref="CancellationToken"/> fires — simulates a server that accepted the connection but never answers.</summary> /// <summary>A handler that never completes the request until its <see cref="CancellationToken"/> fires — simulates a server that accepted the connection but never answers.</summary>
private sealed class HangingHandler : HttpMessageHandler private sealed class HangingHandler : HttpMessageHandler
{ {
/// <summary>Gets the number of requests the handler has received.</summary>
public int CallCount { get; private set; } public int CallCount { get; private set; }
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync( protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
@@ -410,17 +451,26 @@ public class AuditExportCommandTests
private readonly byte[] _firstChunk; private readonly byte[] _firstChunk;
private bool _servedFirstChunk; private bool _servedFirstChunk;
/// <summary>Initializes a stream that serves the given first chunk and then stalls.</summary>
/// <param name="firstChunk">The bytes served before the stream goes silent.</param>
public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk; public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk;
/// <inheritdoc />
public override bool CanRead => true; public override bool CanRead => true;
/// <inheritdoc />
public override bool CanSeek => false; public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false; public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => throw new NotSupportedException(); public override long Length => throw new NotSupportedException();
/// <inheritdoc />
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count) => public override int Read(byte[] buffer, int offset, int count) =>
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
/// <inheritdoc />
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{ {
if (!_servedFirstChunk) if (!_servedFirstChunk)
@@ -435,9 +485,13 @@ public class AuditExportCommandTests
return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation
} }
/// <inheritdoc />
public override void Flush() { } public override void Flush() { }
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
/// <inheritdoc />
public override void SetLength(long value) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
} }
@@ -447,17 +501,26 @@ public class AuditExportCommandTests
private readonly byte[] _firstChunk; private readonly byte[] _firstChunk;
private bool _servedFirstChunk; private bool _servedFirstChunk;
/// <summary>Initializes a stream that serves the given first chunk and then throws.</summary>
/// <param name="firstChunk">The bytes served before the stream faults.</param>
public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk; public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk;
/// <inheritdoc />
public override bool CanRead => true; public override bool CanRead => true;
/// <inheritdoc />
public override bool CanSeek => false; public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false; public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => throw new NotSupportedException(); public override long Length => throw new NotSupportedException();
/// <inheritdoc />
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count) => public override int Read(byte[] buffer, int offset, int count) =>
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
/// <inheritdoc />
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{ {
if (!_servedFirstChunk) if (!_servedFirstChunk)
@@ -471,9 +534,13 @@ public class AuditExportCommandTests
throw new IOException("connection reset by peer (simulated)"); throw new IOException("connection reset by peer (simulated)");
} }
/// <inheritdoc />
public override void Flush() { } public override void Flush() { }
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
/// <inheritdoc />
public override void SetLength(long value) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
} }
@@ -494,6 +561,8 @@ public class AuditExportCommandTests
return capture.ToString(); return capture.ToString();
} }
/// <summary>Verifies that a server that never sends response headers causes a timeout with a friendly error message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError() public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError()
{ {
@@ -521,6 +590,8 @@ public class AuditExportCommandTests
Assert.False(File.Exists(path)); Assert.False(File.Exists(path));
} }
/// <summary>Verifies that the inactivity timeout fires when the stream stalls mid-transfer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_StreamStallsMidTransfer_InactivityTimeoutFires() public async Task RunExport_StreamStallsMidTransfer_InactivityTimeoutFires()
{ {
@@ -562,6 +633,8 @@ public class AuditExportCommandTests
} }
} }
/// <summary>Verifies that an I/O exception during streaming produces a clean message rather than a stack trace.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_MidStreamIOException_ProducesCleanMessageNotStackTrace() public async Task RunExport_MidStreamIOException_ProducesCleanMessageNotStackTrace()
{ {
@@ -596,6 +669,8 @@ public class AuditExportCommandTests
} }
} }
/// <summary>Verifies that a Ctrl-C cancellation token cancels the export promptly with a clean message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunExport_CtrlCToken_CancelsPromptlyWithCleanMessage() public async Task RunExport_CtrlCToken_CancelsPromptlyWithCleanMessage()
{ {
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// Tests for the <c>scadabridge audit query</c> subcommand (Audit Log #23 M8-T2): /// Tests for the <c>scadabridge audit query</c> subcommand:
/// time-spec resolution, query-string construction, formatter selection, error /// time-spec resolution, query-string construction, formatter selection, error
/// handling, and keyset-cursor paging via <c>--all</c>. /// handling, and keyset-cursor paging via <c>--all</c>.
/// </summary> /// </summary>
@@ -17,6 +17,7 @@ public class AuditQueryCommandTests
{ {
// ---- Time-spec parsing ------------------------------------------------- // ---- Time-spec parsing -------------------------------------------------
/// <summary>Verifies that a relative hours time spec resolves to the current time minus that offset.</summary>
[Fact] [Fact]
public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset() public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset()
{ {
@@ -25,6 +26,7 @@ public class AuditQueryCommandTests
Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved); Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved);
} }
/// <summary>Verifies that a relative days time spec resolves to the current time minus that offset.</summary>
[Fact] [Fact]
public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset() public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset()
{ {
@@ -33,6 +35,7 @@ public class AuditQueryCommandTests
Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved); Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved);
} }
/// <summary>Verifies that an absolute ISO 8601 time spec is parsed verbatim.</summary>
[Fact] [Fact]
public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim() public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim()
{ {
@@ -41,6 +44,7 @@ public class AuditQueryCommandTests
Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved); Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved);
} }
/// <summary>Verifies that an unparseable time spec throws.</summary>
[Fact] [Fact]
public void ResolveTimeSpec_Garbage_Throws() public void ResolveTimeSpec_Garbage_Throws()
{ {
@@ -50,6 +54,7 @@ public class AuditQueryCommandTests
// ---- Query string construction ---------------------------------------- // ---- Query string construction ----------------------------------------
/// <summary>Verifies that a fully populated flag set produces the expected query-string parameters.</summary>
[Fact] [Fact]
public void BuildQueryString_FullFlagSet_ProducesExpectedParameters() public void BuildQueryString_FullFlagSet_ProducesExpectedParameters()
{ {
@@ -90,6 +95,7 @@ public class AuditQueryCommandTests
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
} }
/// <summary>Verifies that the errors-only flag maps to the failed status filter.</summary>
[Fact] [Fact]
public void BuildQueryString_ErrorsOnly_MapsToFailedStatus() public void BuildQueryString_ErrorsOnly_MapsToFailedStatus()
{ {
@@ -100,6 +106,7 @@ public class AuditQueryCommandTests
Assert.Equal("Failed", parsed["status"]); Assert.Equal("Failed", parsed["status"]);
} }
/// <summary>Verifies that a multi-value channel filter emits one query key per value.</summary>
[Fact] [Fact]
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue() public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
{ {
@@ -119,6 +126,7 @@ public class AuditQueryCommandTests
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId")); Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
} }
/// <summary>Verifies that the errors-only flag overrides any explicit status values.</summary>
[Fact] [Fact]
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues() public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
{ {
@@ -137,6 +145,7 @@ public class AuditQueryCommandTests
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status")); Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
} }
/// <summary>Verifies that the paging cursor is appended after the filter parameters.</summary>
[Fact] [Fact]
public void BuildQueryString_Cursor_AppendsAfterParameters() public void BuildQueryString_Cursor_AppendsAfterParameters()
{ {
@@ -149,6 +158,7 @@ public class AuditQueryCommandTests
Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]); Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]);
} }
/// <summary>Verifies that unset filters are omitted from the query string.</summary>
[Fact] [Fact]
public void BuildQueryString_OmitsUnsetFilters() public void BuildQueryString_OmitsUnsetFilters()
{ {
@@ -165,6 +175,7 @@ public class AuditQueryCommandTests
Assert.Equal("100", parsed["pageSize"]); Assert.Equal("100", parsed["pageSize"]);
} }
/// <summary>Verifies that an execution id filter emits the execution id parameter.</summary>
[Fact] [Fact]
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter() public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
{ {
@@ -176,6 +187,7 @@ public class AuditQueryCommandTests
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]); Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
} }
/// <summary>Verifies that a parent execution id filter emits the parent execution id parameter.</summary>
[Fact] [Fact]
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter() public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
{ {
@@ -192,13 +204,17 @@ public class AuditQueryCommandTests
private sealed class RecordingHandler : HttpMessageHandler private sealed class RecordingHandler : HttpMessageHandler
{ {
private readonly Queue<string> _bodies; private readonly Queue<string> _bodies;
/// <summary>The request URIs received by the handler, in order.</summary>
public List<string> RequestUris { get; } = new(); public List<string> RequestUris { get; } = new();
/// <summary>Creates a handler that replies with the given JSON bodies in sequence.</summary>
/// <param name="bodies">The response bodies to return, one per request.</param>
public RecordingHandler(params string[] bodies) public RecordingHandler(params string[] bodies)
{ {
_bodies = new Queue<string>(bodies); _bodies = new Queue<string>(bodies);
} }
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
@@ -211,6 +227,8 @@ public class AuditQueryCommandTests
} }
} }
/// <summary>Verifies that a single page of results is written as JSON lines.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunQuery_SinglePage_WritesEventsAsJsonLines() public async Task RunQuery_SinglePage_WritesEventsAsJsonLines()
{ {
@@ -231,6 +249,8 @@ public class AuditQueryCommandTests
Assert.Single(handler.RequestUris); Assert.Single(handler.RequestUris);
} }
/// <summary>Verifies that the all flag follows the next cursor across pages.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages() public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages()
{ {
@@ -251,6 +271,8 @@ public class AuditQueryCommandTests
Assert.Equal(2, lines.Length); Assert.Equal(2, lines.Length);
} }
/// <summary>Verifies that without the all flag only the first page is fetched even when a cursor is present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent() public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent()
{ {
@@ -267,6 +289,8 @@ public class AuditQueryCommandTests
Assert.Single(handler.RequestUris); Assert.Single(handler.RequestUris);
} }
/// <summary>Verifies that a server error returns a non-zero exit code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunQuery_ServerError_ReturnsNonZeroExit() public async Task RunQuery_ServerError_ReturnsNonZeroExit()
{ {
@@ -281,12 +305,14 @@ public class AuditQueryCommandTests
Assert.NotEqual(0, exit); Assert.NotEqual(0, exit);
} }
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunQuery_Http403_ReturnsExitCode2() public async Task RunQuery_Http403_ReturnsExitCode2()
{ {
// CLI-018: an authorization failure on /api/audit/query (HTTP 403) must // An authorization failure on /api/audit/query (HTTP 403) must
// produce exit code 2 per the documented CLI exit-code contract — the // produce exit code 2 per the documented CLI exit-code contract — the
// legacy bare-1 return masked auth failures as generic command failures. // legacy bare exit code 1 return masked auth failures as generic command failures.
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}"); var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter(); var output = new StringWriter();
@@ -298,11 +324,13 @@ public class AuditQueryCommandTests
Assert.Equal(2, exit); Assert.Equal(2, exit);
} }
/// <summary>Verifies that an UNAUTHORIZED error code on a status other than 403 returns exit code 2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2() public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{ {
// The server may signal authorization failure via the error code on a // The server may signal authorization failure via the error code on a
// non-403 status (e.g. 400 + code=UNAUTHORIZED). Honour both channels. // status other than 403 (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}"); var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter(); var output = new StringWriter();
@@ -314,10 +342,12 @@ public class AuditQueryCommandTests
Assert.Equal(2, exit); Assert.Equal(2, exit);
} }
/// <summary>Verifies that a generic server error returns exit code 1.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunQuery_GenericServerError_ReturnsExitCode1() public async Task RunQuery_GenericServerError_ReturnsExitCode1()
{ {
// Authentication / internal errors (non-403, no auth code) must remain // Authentication / internal errors (status other than 403, no auth code) must remain
// exit code 1 — exit 2 is reserved for authorization failures. // exit code 1 — exit 2 is reserved for authorization failures.
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}"); var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
@@ -332,6 +362,7 @@ public class AuditQueryCommandTests
private sealed class ErrorHandler : HttpMessageHandler private sealed class ErrorHandler : HttpMessageHandler
{ {
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
@@ -344,7 +375,11 @@ public class AuditQueryCommandTests
{ {
private readonly HttpStatusCode _status; private readonly HttpStatusCode _status;
private readonly string _body; private readonly string _body;
/// <summary>Creates a handler that always replies with the given status and body.</summary>
/// <param name="status">The HTTP status code to return.</param>
/// <param name="body">The response body to return.</param>
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; } public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) }); => Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
@@ -352,6 +387,7 @@ public class AuditQueryCommandTests
// ---- CLI parsing ------------------------------------------------------- // ---- CLI parsing -------------------------------------------------------
/// <summary>Verifies that an unknown flag produces a parse error and a non-zero exit code.</summary>
[Fact] [Fact]
public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit() public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit()
{ {
@@ -361,6 +397,7 @@ public class AuditQueryCommandTests
Assert.NotEqual("", err); Assert.NotEqual("", err);
} }
/// <summary>Verifies that the table output format is accepted.</summary>
[Fact] [Fact]
public void Query_FormatTable_IsAccepted() public void Query_FormatTable_IsAccepted()
{ {
@@ -369,6 +406,7 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the execution id option is accepted.</summary>
[Fact] [Fact]
public void Query_ExecutionIdOption_IsAccepted() public void Query_ExecutionIdOption_IsAccepted()
{ {
@@ -381,6 +419,7 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the parent execution id option is accepted.</summary>
[Fact] [Fact]
public void Query_ParentExecutionIdOption_IsAccepted() public void Query_ParentExecutionIdOption_IsAccepted()
{ {
@@ -395,6 +434,7 @@ public class AuditQueryCommandTests
// ---- Enum-name validation (fast-fail) ---------------------------------- // ---- Enum-name validation (fast-fail) ----------------------------------
/// <summary>Verifies that a channel option using a real enum name is accepted.</summary>
[Fact] [Fact]
public void Query_ChannelWithRealEnumName_IsAccepted() public void Query_ChannelWithRealEnumName_IsAccepted()
{ {
@@ -403,6 +443,7 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that multiple channel values in a single token are accepted.</summary>
[Fact] [Fact]
public void Query_MultipleChannelValues_SingleToken_AreAccepted() public void Query_MultipleChannelValues_SingleToken_AreAccepted()
{ {
@@ -412,6 +453,7 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that multiple channel values given by repeating the flag are accepted.</summary>
[Fact] [Fact]
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted() public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
{ {
@@ -438,6 +480,9 @@ public class AuditQueryCommandTests
// carried through to the request rather than rejected locally. // carried through to the request rather than rejected locally.
// ======================================================================== // ========================================================================
/// <summary>Verifies that an unrecognised filter value is not rejected at the parse layer.</summary>
/// <param name="option">The filter option name.</param>
/// <param name="value">The filter value to pass.</param>
[Theory] [Theory]
[InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound" [InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound"
[InlineData("--kind", "CachedCall")] [InlineData("--kind", "CachedCall")]
@@ -449,6 +494,7 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that a multi-value channel filter containing one unrecognised name still parses.</summary>
[Fact] [Fact]
public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses() public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses()
{ {
@@ -459,6 +505,9 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that a filter value in any letter case is accepted.</summary>
/// <param name="option">The filter option name.</param>
/// <param name="value">The filter value to pass.</param>
[Theory] [Theory]
[InlineData("--channel", "apioutbound")] [InlineData("--channel", "apioutbound")]
[InlineData("--kind", "apicall")] [InlineData("--kind", "apicall")]
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// Tests for the <c>table</c> output formatter of the <c>scadabridge audit query</c> /// Tests for the <c>table</c> output formatter of the <c>scadabridge audit query</c>
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the /// subcommand: header rendering, long-field truncation, the
/// empty-result-set case, and null-actor handling. /// empty-result-set case, and null-actor handling.
/// </summary> /// </summary>
public class AuditTableFormatterTests public class AuditTableFormatterTests
@@ -18,6 +18,7 @@ public class AuditTableFormatterTests
.ToList(); .ToList();
} }
/// <summary>Verifies that the table renders a header row with the expected columns.</summary>
[Fact] [Fact]
public void Table_RendersHeaderRow_WithExpectedColumns() public void Table_RendersHeaderRow_WithExpectedColumns()
{ {
@@ -38,6 +39,7 @@ public class AuditTableFormatterTests
} }
} }
/// <summary>Verifies that a long target is truncated with an ellipsis.</summary>
[Fact] [Fact]
public void Table_TruncatesLongTarget_WithEllipsis() public void Table_TruncatesLongTarget_WithEllipsis()
{ {
@@ -57,6 +59,7 @@ public class AuditTableFormatterTests
Assert.DoesNotContain(longTarget, text); Assert.DoesNotContain(longTarget, text);
} }
/// <summary>Verifies that an empty result set renders only the header or a no-rows message.</summary>
[Fact] [Fact]
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage() public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
{ {
@@ -73,6 +76,7 @@ public class AuditTableFormatterTests
Assert.Contains("OccurredAtUtc", lines[0]); Assert.Contains("OccurredAtUtc", lines[0]);
} }
/// <summary>Verifies that a null actor renders as a blank cell.</summary>
[Fact] [Fact]
public void Table_NullActor_RendersBlank() public void Table_NullActor_RendersBlank()
{ {
@@ -93,6 +97,7 @@ public class AuditTableFormatterTests
Assert.Contains("InboundApi", lines[1]); Assert.Contains("InboundApi", lines[1]);
} }
/// <summary>Verifies that the header is emitted once per page and the data rows are aligned.</summary>
[Fact] [Fact]
public void Table_HeaderEmittedOncePerPage_DataRowsAligned() public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
{ {
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// Tests for the <c>scadabridge audit tree</c> subcommand (Audit Log #23 M5.1-T8): /// Tests for the <c>scadabridge audit tree</c> subcommand:
/// tree rendering (table format), JSON output, error handling, and CLI parsing. /// tree rendering (table format), JSON output, error handling, and CLI parsing.
/// </summary> /// </summary>
[Collection("Console")] [Collection("Console")]
@@ -58,6 +58,7 @@ public class AuditTreeCommandTests
// ParseNodes // ParseNodes
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that parsing a valid JSON array returns the matching node DTOs.</summary>
[Fact] [Fact]
public void ParseNodes_ValidArray_ReturnsDtos() public void ParseNodes_ValidArray_ReturnsDtos()
{ {
@@ -75,6 +76,7 @@ public class AuditTreeCommandTests
Assert.Equal(3, nodes[0].RowCount); Assert.Equal(3, nodes[0].RowCount);
} }
/// <summary>Verifies that parsing an empty JSON array returns an empty list.</summary>
[Fact] [Fact]
public void ParseNodes_EmptyArray_ReturnsEmpty() public void ParseNodes_EmptyArray_ReturnsEmpty()
{ {
@@ -82,6 +84,7 @@ public class AuditTreeCommandTests
Assert.Empty(nodes); Assert.Empty(nodes);
} }
/// <summary>Verifies that parsing invalid JSON returns an empty list.</summary>
[Fact] [Fact]
public void ParseNodes_InvalidJson_ReturnsEmpty() public void ParseNodes_InvalidJson_ReturnsEmpty()
{ {
@@ -93,6 +96,7 @@ public class AuditTreeCommandTests
// WriteTable — ASCII tree rendering // WriteTable — ASCII tree rendering
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that writing a table for no nodes prints the fallback message.</summary>
[Fact] [Fact]
public void WriteTable_EmptyNodes_PrintsFallbackMessage() public void WriteTable_EmptyNodes_PrintsFallbackMessage()
{ {
@@ -101,6 +105,7 @@ public class AuditTreeCommandTests
Assert.Contains("no execution tree found", output.ToString()); Assert.Contains("no execution tree found", output.ToString());
} }
/// <summary>Verifies that writing a table for a single root node prints it with no indent.</summary>
[Fact] [Fact]
public void WriteTable_SingleRootNode_PrintsWithNoIndent() public void WriteTable_SingleRootNode_PrintsWithNoIndent()
{ {
@@ -117,6 +122,7 @@ public class AuditTreeCommandTests
Assert.Contains("[*]", line); // queried node marked Assert.Contains("[*]", line); // queried node marked
} }
/// <summary>Verifies that writing a table for a multi-level tree indents each child level correctly.</summary>
[Fact] [Fact]
public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly() public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly()
{ {
@@ -144,6 +150,7 @@ public class AuditTreeCommandTests
Assert.StartsWith(" ", grandLine); Assert.StartsWith(" ", grandLine);
} }
/// <summary>Verifies that writing a table marks the queried node and leaves the others unmarked.</summary>
[Fact] [Fact]
public void WriteTable_QueriedNodeIsMarked_OthersAreNot() public void WriteTable_QueriedNodeIsMarked_OthersAreNot()
{ {
@@ -167,6 +174,7 @@ public class AuditTreeCommandTests
// WriteJson // WriteJson
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that writing JSON for valid nodes emits a valid JSON array.</summary>
[Fact] [Fact]
public void WriteJson_ValidNodes_EmitsValidJsonArray() public void WriteJson_ValidNodes_EmitsValidJsonArray()
{ {
@@ -183,6 +191,7 @@ public class AuditTreeCommandTests
Assert.Equal(2, doc.RootElement.GetArrayLength()); Assert.Equal(2, doc.RootElement.GetArrayLength());
} }
/// <summary>Verifies that writing JSON for no nodes emits an empty array.</summary>
[Fact] [Fact]
public void WriteJson_EmptyNodes_EmitsEmptyArray() public void WriteJson_EmptyNodes_EmitsEmptyArray()
{ {
@@ -204,14 +213,19 @@ public class AuditTreeCommandTests
private readonly HttpStatusCode _status; private readonly HttpStatusCode _status;
private readonly string _body; private readonly string _body;
/// <summary>Initializes a new instance of the <c>FixedHandler</c> class.</summary>
/// <param name="status">The status.</param>
/// <param name="body">The body.</param>
public FixedHandler(HttpStatusCode status, string body) public FixedHandler(HttpStatusCode status, string body)
{ {
_status = status; _status = status;
_body = body; _body = body;
} }
/// <summary>Gets the last request uri value.</summary>
public string? LastRequestUri { get; private set; } public string? LastRequestUri { get; private set; }
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
@@ -223,6 +237,8 @@ public class AuditTreeCommandTests
} }
} }
/// <summary>Verifies that a successful tree run returns exit code zero and writes the output.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunTree_Success_ReturnsZeroAndWritesOutput() public async Task RunTree_Success_ReturnsZeroAndWritesOutput()
{ {
@@ -239,6 +255,8 @@ public class AuditTreeCommandTests
Assert.Contains(rootId, output.ToString()); Assert.Contains(rootId, output.ToString());
} }
/// <summary>Verifies that a tree run with an empty response returns exit code zero and prints the fallback message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage() public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage()
{ {
@@ -253,6 +271,8 @@ public class AuditTreeCommandTests
Assert.Contains("no execution tree found", output.ToString()); Assert.Contains("no execution tree found", output.ToString());
} }
/// <summary>Verifies that a tree run with the JSON format emits valid JSON.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunTree_JsonFormat_EmitsValidJson() public async Task RunTree_JsonFormat_EmitsValidJson()
{ {
@@ -269,6 +289,8 @@ public class AuditTreeCommandTests
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
} }
/// <summary>Verifies that a tree run that receives HTTP 403 returns exit code 2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunTree_Http403_ReturnsExitCode2() public async Task RunTree_Http403_ReturnsExitCode2()
{ {
@@ -282,6 +304,8 @@ public class AuditTreeCommandTests
Assert.Equal(2, exit); Assert.Equal(2, exit);
} }
/// <summary>Verifies that a tree run that receives HTTP 500 returns exit code 1.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunTree_Http500_ReturnsExitCode1() public async Task RunTree_Http500_ReturnsExitCode1()
{ {
@@ -295,6 +319,8 @@ public class AuditTreeCommandTests
Assert.Equal(1, exit); Assert.Equal(1, exit);
} }
/// <summary>Verifies that the tree request URL contains the execution id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunTree_RequestUrlContainsExecutionId() public async Task RunTree_RequestUrlContainsExecutionId()
{ {
@@ -313,6 +339,7 @@ public class AuditTreeCommandTests
// CLI parsing — audit tree subcommand // CLI parsing — audit tree subcommand
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the tree subcommand exists in the audit command group.</summary>
[Fact] [Fact]
public void Tree_Subcommand_ExistsInAuditCommandGroup() public void Tree_Subcommand_ExistsInAuditCommandGroup()
{ {
@@ -322,6 +349,7 @@ public class AuditTreeCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the tree execution id option is required.</summary>
[Fact] [Fact]
public void Tree_ExecutionIdOption_IsRequired() public void Tree_ExecutionIdOption_IsRequired()
{ {
@@ -332,6 +360,7 @@ public class AuditTreeCommandTests
Assert.NotEqual(0, exit); Assert.NotEqual(0, exit);
} }
/// <summary>Verifies that the tree help text describes the execution id.</summary>
[Fact] [Fact]
public void Tree_HelpText_DescribesExecutionId() public void Tree_HelpText_DescribesExecutionId()
{ {
@@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand (Audit Log #23 M8-T4). /// Tests for the <c>scadabridge audit verify-chain</c> subcommand.
/// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled" /// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled"
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero. /// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
/// </summary> /// </summary>
[Collection("Console")] [Collection("Console")]
public class AuditVerifyChainCommandTests public class AuditVerifyChainCommandTests
{ {
/// <summary>Verifies that verifying the chain for a valid month exits with zero and prints the documented message.</summary>
[Fact] [Fact]
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage() public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
{ {
@@ -22,6 +23,7 @@ public class AuditVerifyChainCommandTests
Assert.Contains("Component-AuditLog.md", output); Assert.Contains("Component-AuditLog.md", output);
} }
/// <summary>Verifies that a malformed month value makes the command exit with a non-zero code.</summary>
[Fact] [Fact]
public void VerifyChain_MalformedMonth_ExitsNonZero() public void VerifyChain_MalformedMonth_ExitsNonZero()
{ {
@@ -32,6 +34,7 @@ public class AuditVerifyChainCommandTests
Assert.NotEqual(0, exit); Assert.NotEqual(0, exit);
} }
/// <summary>Verifies that omitting the month option produces the required-option error.</summary>
[Fact] [Fact]
public void VerifyChain_MissingMonth_ProducesRequiredFlagError() public void VerifyChain_MissingMonth_ProducesRequiredFlagError()
{ {
@@ -42,6 +45,9 @@ public class AuditVerifyChainCommandTests
Assert.Contains("--month", err); Assert.Contains("--month", err);
} }
/// <summary>Verifies that month validation accepts only values in <c>yyyy-MM</c> format.</summary>
/// <param name="month">The month string to validate.</param>
/// <param name="expected">Whether the month is expected to be valid.</param>
[Theory] [Theory]
[InlineData("2026-05", true)] [InlineData("2026-05", true)]
[InlineData("2026-01", true)] [InlineData("2026-01", true)]
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// CLI-019 regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>. /// Regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>, /// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new /// doubling the bundle's bytes onto the LOH and writing synchronously. The new
/// streaming helper decodes the base64 string in fixed-size chunks straight into /// streaming helper decodes the base64 string in fixed-size chunks straight into
@@ -15,11 +15,13 @@ public class BundleCommandsStreamingTests : IDisposable
{ {
private readonly string _tempPath; private readonly string _tempPath;
/// <summary>Creates a unique temporary output path for each test.</summary>
public BundleCommandsStreamingTests() public BundleCommandsStreamingTests()
{ {
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin"); _tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
} }
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose() public void Dispose()
{ {
if (File.Exists(_tempPath)) if (File.Exists(_tempPath))
@@ -28,6 +30,7 @@ public class BundleCommandsStreamingTests : IDisposable
} }
} }
/// <summary>Verifies that a small payload is written to the file and read back unchanged.</summary>
[Fact] [Fact]
public void StreamBase64ToFile_SmallPayload_RoundTrips() public void StreamBase64ToFile_SmallPayload_RoundTrips()
{ {
@@ -41,6 +44,7 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Equal(bytes, roundTripped); Assert.Equal(bytes, roundTripped);
} }
/// <summary>Verifies that a payload spanning several chunks, with a short final chunk, round-trips unchanged.</summary>
[Fact] [Fact]
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips() public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
{ {
@@ -60,6 +64,7 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Equal(bytes, roundTripped); Assert.Equal(bytes, roundTripped);
} }
/// <summary>Verifies that an empty base64 string produces an empty file.</summary>
[Fact] [Fact]
public void StreamBase64ToFile_EmptyString_WritesEmptyFile() public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
{ {
@@ -70,6 +75,7 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Empty(File.ReadAllBytes(_tempPath)); Assert.Empty(File.ReadAllBytes(_tempPath));
} }
/// <summary>Verifies that invalid base64 text throws a <see cref="FormatException"/>.</summary>
[Fact] [Fact]
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException() public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
{ {
@@ -81,20 +87,23 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath)); Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
} }
/// <summary>Verifies that a null base64 string throws an <see cref="ArgumentNullException"/>.</summary>
[Fact] [Fact]
public void StreamBase64ToFile_NullBase64_Throws() public void StreamBase64ToFile_NullBase64_Throws()
{ {
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath)); Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
} }
/// <summary>Verifies that an empty output path throws an <see cref="ArgumentException"/>.</summary>
[Fact] [Fact]
public void StreamBase64ToFile_EmptyOutputPath_Throws() public void StreamBase64ToFile_EmptyOutputPath_Throws()
{ {
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty)); Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
} }
// ---- M8 (B4): --sites / --instances comma-split + flag parsing ---------- // ---- --sites / --instances comma-split + flag parsing ----------
/// <summary>Verifies that a comma-separated list is split and each name is trimmed.</summary>
[Fact] [Fact]
public void ParseNameList_CommaSeparated_SplitsAndTrims() public void ParseNameList_CommaSeparated_SplitsAndTrims()
{ {
@@ -103,6 +112,8 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result); Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result);
} }
/// <summary>Verifies that a null or blank token yields a null list.</summary>
/// <param name="token">The raw option value to parse.</param>
[Theory] [Theory]
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
@@ -112,6 +123,7 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Null(BundleCommands.ParseNameList(token)); Assert.Null(BundleCommands.ParseNameList(token));
} }
/// <summary>Verifies that empty and whitespace-only entries are dropped from the list.</summary>
[Fact] [Fact]
public void ParseNameList_EmptyEntries_AreDropped() public void ParseNameList_EmptyEntries_AreDropped()
{ {
@@ -119,6 +131,7 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Equal(new[] { "a", "b", "c" }, result); Assert.Equal(new[] { "a", "b", "c" }, result);
} }
/// <summary>Verifies that the export command accepts the sites and instances flags without parse errors.</summary>
[Fact] [Fact]
public void BundleExport_SitesAndInstancesFlags_ParseWithoutError() public void BundleExport_SitesAndInstancesFlags_ParseWithoutError()
{ {
@@ -138,14 +151,16 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
// ---- M8 (D3): --map-site / --map-connection spec parsing ---------------- // ---- --map-site / --map-connection spec parsing ----------------
/// <summary>Verifies that a null mapping list yields no site mappings.</summary>
[Fact] [Fact]
public void ParseSiteMappings_Null_ReturnsEmpty() public void ParseSiteMappings_Null_ReturnsEmpty()
{ {
Assert.Empty(BundleCommands.ParseSiteMappings(null)); Assert.Empty(BundleCommands.ParseSiteMappings(null));
} }
/// <summary>Verifies that a source=target spec maps the source site to an existing target site.</summary>
[Fact] [Fact]
public void ParseSiteMappings_SrcEqualsDst_MapsToExisting() public void ParseSiteMappings_SrcEqualsDst_MapsToExisting()
{ {
@@ -155,6 +170,8 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Equal("PLANT-A", spec.TargetSiteIdentifier); Assert.Equal("PLANT-A", spec.TargetSiteIdentifier);
} }
/// <summary>Verifies that a spec with no target means the site should be created new.</summary>
/// <param name="token">The raw site mapping spec.</param>
[Theory] [Theory]
[InlineData("NORTH-01")] // no '=' part -> create-new [InlineData("NORTH-01")] // no '=' part -> create-new
[InlineData("NORTH-01=")] // empty rhs -> create-new [InlineData("NORTH-01=")] // empty rhs -> create-new
@@ -167,6 +184,7 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Null(spec.TargetSiteIdentifier); Assert.Null(spec.TargetSiteIdentifier);
} }
/// <summary>Verifies that repeated mapping specs accumulate into one list.</summary>
[Fact] [Fact]
public void ParseSiteMappings_Repeated_AccumulatesAll() public void ParseSiteMappings_Repeated_AccumulatesAll()
{ {
@@ -176,12 +194,14 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Null(specs[1].TargetSiteIdentifier); Assert.Null(specs[1].TargetSiteIdentifier);
} }
/// <summary>Verifies that a spec with an empty source throws a <see cref="FormatException"/>.</summary>
[Fact] [Fact]
public void ParseSiteMappings_EmptySource_Throws() public void ParseSiteMappings_EmptySource_Throws()
{ {
Assert.Throws<FormatException>(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" })); Assert.Throws<FormatException>(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" }));
} }
/// <summary>Verifies that a site/name=target spec maps the source connection to an existing target connection.</summary>
[Fact] [Fact]
public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting() public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting()
{ {
@@ -192,6 +212,8 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Equal("OpcLive", spec.TargetConnectionName); Assert.Equal("OpcLive", spec.TargetConnectionName);
} }
/// <summary>Verifies that a connection spec with no target means the connection should be created new.</summary>
/// <param name="token">The raw connection mapping spec.</param>
[Theory] [Theory]
[InlineData("NORTH-01/OpcA")] // no '=' part -> create-new [InlineData("NORTH-01/OpcA")] // no '=' part -> create-new
[InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new [InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new
@@ -204,6 +226,8 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Null(spec.TargetConnectionName); Assert.Null(spec.TargetConnectionName);
} }
/// <summary>Verifies that a connection spec with a malformed source throws a <see cref="FormatException"/>.</summary>
/// <param name="token">The malformed connection mapping spec.</param>
[Theory] [Theory]
[InlineData("OpcA")] // missing site/name shape [InlineData("OpcA")] // missing site/name shape
[InlineData("NORTH-01/")] // empty connection name [InlineData("NORTH-01/")] // empty connection name
@@ -213,6 +237,7 @@ public class BundleCommandsStreamingTests : IDisposable
Assert.Throws<FormatException>(() => BundleCommands.ParseConnectionMappings(new[] { token })); Assert.Throws<FormatException>(() => BundleCommands.ParseConnectionMappings(new[] { token }));
} }
/// <summary>Verifies that the import command accepts the map and create-missing flags without parse errors.</summary>
[Fact] [Fact]
public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError() public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
{ {
@@ -21,6 +21,7 @@ public class CachedCallCommandsTests
// ── retry ────────────────────────────────────────────────────────────── // ── retry ──────────────────────────────────────────────────────────────
/// <summary>Verifies that the retry command with a site id and message id produces the expected management command.</summary>
[Fact] [Fact]
public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand() public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand()
{ {
@@ -37,6 +38,7 @@ public class CachedCallCommandsTests
Assert.Equal("11111111-2222-3333-4444-555555555555", cmd.MessageId); Assert.Equal("11111111-2222-3333-4444-555555555555", cmd.MessageId);
} }
/// <summary>Verifies that the retry command requires both the site id and the tracked operation id.</summary>
[Fact] [Fact]
public void Retry_SiteIdAndTrackedOperationId_AreRequired() public void Retry_SiteIdAndTrackedOperationId_AreRequired()
{ {
@@ -50,6 +52,7 @@ public class CachedCallCommandsTests
Assert.True(opId.Required, "--tracked-operation-id must be required."); Assert.True(opId.Required, "--tracked-operation-id must be required.");
} }
/// <summary>Verifies that the retry command reports parse errors when its options are missing.</summary>
[Fact] [Fact]
public void Retry_MissingOptions_ProducesParseErrors() public void Retry_MissingOptions_ProducesParseErrors()
{ {
@@ -63,6 +66,7 @@ public class CachedCallCommandsTests
// ── discard ──────────────────────────────────────────────────────────── // ── discard ────────────────────────────────────────────────────────────
/// <summary>Verifies that the discard command with a site id and message id produces the expected management command.</summary>
[Fact] [Fact]
public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand() public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand()
{ {
@@ -79,6 +83,7 @@ public class CachedCallCommandsTests
Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", cmd.MessageId); Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", cmd.MessageId);
} }
/// <summary>Verifies that the discard command requires both the site id and the tracked operation id.</summary>
[Fact] [Fact]
public void Discard_SiteIdAndTrackedOperationId_AreRequired() public void Discard_SiteIdAndTrackedOperationId_AreRequired()
{ {
@@ -92,6 +97,7 @@ public class CachedCallCommandsTests
Assert.True(opId.Required, "--tracked-operation-id must be required."); Assert.True(opId.Required, "--tracked-operation-id must be required.");
} }
/// <summary>Verifies that the discard command reports parse errors when its options are missing.</summary>
[Fact] [Fact]
public void Discard_MissingOptions_ProducesParseErrors() public void Discard_MissingOptions_ProducesParseErrors()
{ {
@@ -105,6 +111,7 @@ public class CachedCallCommandsTests
// ── group shape ──────────────────────────────────────────────────────── // ── group shape ────────────────────────────────────────────────────────
/// <summary>Verifies that the cached-call command group contains the retry and discard subcommands.</summary>
[Fact] [Fact]
public void CachedCallGroup_HasRetryAndDiscard() public void CachedCallGroup_HasRetryAndDiscard()
{ {
@@ -115,6 +122,7 @@ public class CachedCallCommandsTests
Assert.Contains("discard", subNames); Assert.Contains("discard", subNames);
} }
/// <summary>Verifies that the cached-call command group is named cached-call.</summary>
[Fact] [Fact]
public void CachedCallGroup_Name_IsCachedCall() public void CachedCallGroup_Name_IsCachedCall()
{ {
@@ -124,6 +132,7 @@ public class CachedCallCommandsTests
// ── registry round-trip ──────────────────────────────────────────────── // ── registry round-trip ────────────────────────────────────────────────
/// <summary>Verifies that the retry-parked-message command resolves through the command registry.</summary>
[Fact] [Fact]
public void RetryParkedMessageCommand_ResolvesViaRegistry() public void RetryParkedMessageCommand_ResolvesViaRegistry()
{ {
@@ -132,6 +141,7 @@ public class CachedCallCommandsTests
Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name)); Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name));
} }
/// <summary>Verifies that the discard-parked-message command resolves through the command registry.</summary>
[Fact] [Fact]
public void DiscardParkedMessageCommand_ResolvesViaRegistry() public void DiscardParkedMessageCommand_ResolvesViaRegistry()
{ {
@@ -26,6 +26,8 @@ public class ExternalSystemRetryOptionTests
private static Option? FindOption(Command command, string name) private static Option? FindOption(Command command, string name)
=> command.Options.SingleOrDefault(o => o.Name == name); => command.Options.SingleOrDefault(o => o.Name == name);
/// <summary>Verifies that the create and update subcommands expose the optional retry-policy options.</summary>
/// <param name="subcommand">The name of the subcommand under test.</param>
[Theory] [Theory]
[InlineData("create")] [InlineData("create")]
[InlineData("update")] [InlineData("update")]
@@ -42,6 +44,7 @@ public class ExternalSystemRetryOptionTests
Assert.False(retryDelay!.Required); Assert.False(retryDelay!.Required);
} }
/// <summary>Verifies that the create subcommand parses the supplied retry-policy values.</summary>
[Fact] [Fact]
public void Create_ParsesRetryPolicyValues() public void Create_ParsesRetryPolicyValues()
{ {
@@ -56,6 +59,7 @@ public class ExternalSystemRetryOptionTests
Assert.Equal(60, parse.GetValue<int?>("--retry-delay-seconds")); Assert.Equal(60, parse.GetValue<int?>("--retry-delay-seconds"));
} }
/// <summary>Verifies that the update subcommand parses the supplied retry-policy values.</summary>
[Fact] [Fact]
public void Update_ParsesRetryPolicyValues() public void Update_ParsesRetryPolicyValues()
{ {
@@ -70,6 +74,7 @@ public class ExternalSystemRetryOptionTests
Assert.Equal(10, parse.GetValue<int?>("--retry-delay-seconds")); Assert.Equal(10, parse.GetValue<int?>("--retry-delay-seconds"));
} }
/// <summary>Verifies that the retry-delay option alias is accepted by the parser.</summary>
[Fact] [Fact]
public void RetryDelayAliasIsAccepted() public void RetryDelayAliasIsAccepted()
{ {
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// PLAN-06 Task 11: <c>external-system create</c> and <c>external-system update</c> /// <c>external-system create</c> and <c>external-system update</c>
/// must expose an optional <c>--timeout-seconds</c> (<see cref="int"/>?) flag that /// must expose an optional <c>--timeout-seconds</c> (<see cref="int"/>?) flag that
/// carries the per-system ESG HTTP timeout. Omitting it preserves the existing value /// carries the per-system ESG HTTP timeout. Omitting it preserves the existing value
/// (update) / uses the gateway default (create). /// (update) / uses the gateway default (create).
@@ -21,6 +21,7 @@ public class ExternalSystemTimeoutOptionTests
private static Option? FindOption(Command command, string name) private static Option? FindOption(Command command, string name)
=> command.Options.SingleOrDefault(o => o.Name == name); => command.Options.SingleOrDefault(o => o.Name == name);
/// <summary>Verifies that the create command exposes an optional timeout-seconds option.</summary>
[Fact] [Fact]
public void Create_HasOptionalTimeoutSecondsOption() public void Create_HasOptionalTimeoutSecondsOption()
{ {
@@ -30,6 +31,7 @@ public class ExternalSystemTimeoutOptionTests
Assert.False(option!.Required); Assert.False(option!.Required);
} }
/// <summary>Verifies that the update command exposes an optional timeout-seconds option.</summary>
[Fact] [Fact]
public void Update_HasOptionalTimeoutSecondsOption() public void Update_HasOptionalTimeoutSecondsOption()
{ {
@@ -39,6 +41,7 @@ public class ExternalSystemTimeoutOptionTests
Assert.False(option!.Required); Assert.False(option!.Required);
} }
/// <summary>Verifies that the create command parses a supplied timeout-seconds value.</summary>
[Fact] [Fact]
public void Create_ParsesTimeoutSecondsValue() public void Create_ParsesTimeoutSecondsValue()
{ {
@@ -52,6 +55,7 @@ public class ExternalSystemTimeoutOptionTests
Assert.Equal(15, parse.GetValue<int?>("--timeout-seconds")); Assert.Equal(15, parse.GetValue<int?>("--timeout-seconds"));
} }
/// <summary>Verifies that the update command parses a supplied timeout-seconds value.</summary>
[Fact] [Fact]
public void Update_ParsesTimeoutSecondsValue() public void Update_ParsesTimeoutSecondsValue()
{ {
@@ -65,6 +69,7 @@ public class ExternalSystemTimeoutOptionTests
Assert.Equal(20, parse.GetValue<int?>("--timeout-seconds")); Assert.Equal(20, parse.GetValue<int?>("--timeout-seconds"));
} }
/// <summary>Verifies that omitting timeout-seconds on create parses without error.</summary>
[Fact] [Fact]
public void Create_OmittedTimeoutSeconds_ParsesWithoutError() public void Create_OmittedTimeoutSeconds_ParsesWithoutError()
{ {
@@ -40,6 +40,7 @@ public class NotificationSmsCommandTests
// ---- list create/update: --type + --phones ---------------------------- // ---- list create/update: --type + --phones ----------------------------
/// <summary>Verifies that creating an SMS list with phones builds an SMS command carrying the recipient phones.</summary>
[Fact] [Fact]
public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones() public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
{ {
@@ -55,6 +56,7 @@ public class NotificationSmsCommandTests
Assert.Empty(cmd.RecipientEmails); Assert.Empty(cmd.RecipientEmails);
} }
/// <summary>Verifies that the list type option is matched case-insensitively.</summary>
[Fact] [Fact]
public void ListCreate_TypeIsCaseInsensitive() public void ListCreate_TypeIsCaseInsensitive()
{ {
@@ -66,6 +68,7 @@ public class NotificationSmsCommandTests
Assert.Equal(NotificationType.Sms, cmd.Type); Assert.Equal(NotificationType.Sms, cmd.Type);
} }
/// <summary>Verifies that a list created without a type defaults to email.</summary>
[Fact] [Fact]
public void ListCreate_DefaultType_IsEmail() public void ListCreate_DefaultType_IsEmail()
{ {
@@ -79,6 +82,7 @@ public class NotificationSmsCommandTests
Assert.Null(cmd.RecipientPhones); Assert.Null(cmd.RecipientPhones);
} }
/// <summary>Verifies that an email list given phone numbers is rejected.</summary>
[Fact] [Fact]
public void ListCreate_EmailTypeWithPhones_Throws() public void ListCreate_EmailTypeWithPhones_Throws()
{ {
@@ -90,6 +94,7 @@ public class NotificationSmsCommandTests
Assert.Contains("--phones", ex.Message); Assert.Contains("--phones", ex.Message);
} }
/// <summary>Verifies that an SMS list with no phone numbers is rejected.</summary>
[Fact] [Fact]
public void ListCreate_SmsTypeWithoutPhones_Throws() public void ListCreate_SmsTypeWithoutPhones_Throws()
{ {
@@ -100,6 +105,7 @@ public class NotificationSmsCommandTests
Assert.Contains("--phones", ex.Message); Assert.Contains("--phones", ex.Message);
} }
/// <summary>Verifies that an email list with no email addresses is rejected.</summary>
[Fact] [Fact]
public void ListCreate_EmailTypeWithoutEmails_Throws() public void ListCreate_EmailTypeWithoutEmails_Throws()
{ {
@@ -110,6 +116,7 @@ public class NotificationSmsCommandTests
Assert.Contains("--emails", ex.Message); Assert.Contains("--emails", ex.Message);
} }
/// <summary>Verifies that an SMS list given email addresses is rejected.</summary>
[Fact] [Fact]
public void ListCreate_SmsTypeWithEmails_Throws() public void ListCreate_SmsTypeWithEmails_Throws()
{ {
@@ -121,6 +128,7 @@ public class NotificationSmsCommandTests
Assert.Contains("--emails", ex.Message); Assert.Contains("--emails", ex.Message);
} }
/// <summary>Verifies that an unknown list type is rejected.</summary>
[Fact] [Fact]
public void ListCreate_UnknownType_Throws() public void ListCreate_UnknownType_Throws()
{ {
@@ -131,6 +139,7 @@ public class NotificationSmsCommandTests
() => NotificationCommands.BuildCreateNotificationListCommand(parse)); () => NotificationCommands.BuildCreateNotificationListCommand(parse));
} }
/// <summary>Verifies that updating an SMS list with phones builds an SMS command carrying the recipient phones.</summary>
[Fact] [Fact]
public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones() public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
{ {
@@ -146,6 +155,7 @@ public class NotificationSmsCommandTests
Assert.Empty(cmd.RecipientEmails); Assert.Empty(cmd.RecipientEmails);
} }
/// <summary>Verifies that updating an email list with phone numbers is rejected.</summary>
[Fact] [Fact]
public void ListUpdate_EmailTypeWithPhones_Throws() public void ListUpdate_EmailTypeWithPhones_Throws()
{ {
@@ -160,6 +170,7 @@ public class NotificationSmsCommandTests
// ---- notification sms group ------------------------------------------- // ---- notification sms group -------------------------------------------
/// <summary>Verifies that the notification command has an SMS group with list and update subcommands.</summary>
[Fact] [Fact]
public void Notification_HasSmsGroupWithListAndUpdate() public void Notification_HasSmsGroupWithListAndUpdate()
{ {
@@ -168,6 +179,7 @@ public class NotificationSmsCommandTests
Assert.Contains("update", subNames); Assert.Contains("update", subNames);
} }
/// <summary>Verifies that an SMS update with every flag produces a command carrying all of them.</summary>
[Fact] [Fact]
public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem() public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem()
{ {
@@ -187,6 +199,7 @@ public class NotificationSmsCommandTests
Assert.Equal("new-secret", cmd.AuthToken); Assert.Equal("new-secret", cmd.AuthToken);
} }
/// <summary>Verifies that omitting the optional flags produces a command with null values for them.</summary>
[Fact] [Fact]
public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls() public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls()
{ {
@@ -204,6 +217,7 @@ public class NotificationSmsCommandTests
Assert.Null(cmd.AuthToken); Assert.Null(cmd.AuthToken);
} }
/// <summary>Verifies that the optional SMS update flags are not marked as required.</summary>
[Fact] [Fact]
public void SmsUpdate_OptionalFlags_AreNotRequired() public void SmsUpdate_OptionalFlags_AreNotRequired()
{ {
@@ -214,6 +228,7 @@ public class NotificationSmsCommandTests
Assert.False(update.Options.Single(o => o.Name == "--auth-token").Required); Assert.False(update.Options.Single(o => o.Name == "--auth-token").Required);
} }
/// <summary>Verifies that an SMS update with only a messaging service SID and no from number builds successfully.</summary>
[Fact] [Fact]
public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds() public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds()
{ {
@@ -229,6 +244,7 @@ public class NotificationSmsCommandTests
Assert.Equal("MGonly", cmd.MessagingServiceSid); Assert.Equal("MGonly", cmd.MessagingServiceSid);
} }
/// <summary>Verifies that an SMS update with neither a from number nor a messaging service SID is rejected.</summary>
[Fact] [Fact]
public void SmsUpdate_NeitherFromNorMessagingService_Throws() public void SmsUpdate_NeitherFromNorMessagingService_Throws()
{ {
@@ -239,6 +255,7 @@ public class NotificationSmsCommandTests
Assert.Throws<ArgumentException>(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse)); Assert.Throws<ArgumentException>(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse));
} }
/// <summary>Verifies that both SMS commands resolve through the management command registry.</summary>
[Fact] [Fact]
public void SmsCommands_ResolveViaRegistry() public void SmsCommands_ResolveViaRegistry()
{ {
@@ -262,6 +279,7 @@ public class NotificationSmsCommandTests
[Collection("Console")] [Collection("Console")]
public class NotificationSmsAuthTokenRenderingTests public class NotificationSmsAuthTokenRenderingTests
{ {
/// <summary>Verifies that a rendered SMS configuration list never contains the auth token value.</summary>
[Fact] [Fact]
public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken() public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken()
{ {
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// PLAN-07 Task 17 (arch-review P3): <c>secured-write list</c> must expose <c>--skip</c> /// <c>secured-write list</c> must expose <c>--skip</c>
/// / <c>--take</c> offset-paging options alongside the <c>--status</c> / <c>--site</c> /// / <c>--take</c> offset-paging options alongside the <c>--status</c> / <c>--site</c>
/// filters, and map them onto <see cref="Commons.Messages.Management.ListSecuredWritesCommand"/>. /// filters, and map them onto <see cref="Commons.Messages.Management.ListSecuredWritesCommand"/>.
/// Omitting the paging flags preserves the historical unpaged default (skip 0, take 200). /// Omitting the paging flags preserves the historical unpaged default (skip 0, take 200).
@@ -23,6 +23,7 @@ public class SecuredWriteListCommandTests
private static Option? FindOption(Command command, string name) private static Option? FindOption(Command command, string name)
=> command.Options.SingleOrDefault(o => o.Name == name); => command.Options.SingleOrDefault(o => o.Name == name);
/// <summary>Verifies that the list command exposes the skip and take options.</summary>
[Fact] [Fact]
public void List_HasSkipAndTakeOptions() public void List_HasSkipAndTakeOptions()
{ {
@@ -31,6 +32,7 @@ public class SecuredWriteListCommandTests
Assert.NotNull(FindOption(list, "--take")); Assert.NotNull(FindOption(list, "--take"));
} }
/// <summary>Verifies that the list command parses skip, take and filters and forwards them to the command.</summary>
[Fact] [Fact]
public void List_ParsesSkipTakeAndFilters_ForwardsToCommand() public void List_ParsesSkipTakeAndFilters_ForwardsToCommand()
{ {
@@ -47,6 +49,7 @@ public class SecuredWriteListCommandTests
Assert.Equal(25, command.Take); Assert.Equal(25, command.Take);
} }
/// <summary>Verifies that omitting the paging options uses the unpaged defaults.</summary>
[Fact] [Fact]
public void List_OmittedPaging_UsesUnpagedDefaults() public void List_OmittedPaging_UsesUnpagedDefaults()
{ {
@@ -29,6 +29,7 @@ public class SmtpUpdateCommandTests
private static ParseResult ParseUpdate(params string[] args) private static ParseResult ParseUpdate(params string[] args)
=> SmtpUpdateCommand().Parse(args); => SmtpUpdateCommand().Parse(args);
/// <summary>Verifies that an update with a TLS mode and credentials produces a command carrying both.</summary>
[Fact] [Fact]
public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem() public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem()
{ {
@@ -49,6 +50,7 @@ public class SmtpUpdateCommandTests
Assert.Equal("user:pass", cmd.Credentials); Assert.Equal("user:pass", cmd.Credentials);
} }
/// <summary>Verifies that an update without a TLS mode or credentials produces a command with null values for them.</summary>
[Fact] [Fact]
public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls() public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls()
{ {
@@ -64,6 +66,8 @@ public class SmtpUpdateCommandTests
Assert.Null(cmd.Credentials); Assert.Null(cmd.Credentials);
} }
/// <summary>Verifies that the TLS mode option accepts each canonical value.</summary>
/// <param name="value">The TLS mode value supplied on the command line.</param>
[Theory] [Theory]
[InlineData("None")] [InlineData("None")]
[InlineData("StartTLS")] [InlineData("StartTLS")]
@@ -78,6 +82,8 @@ public class SmtpUpdateCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
/// <summary>Verifies that the TLS mode option rejects values outside the canonical set.</summary>
/// <param name="value">The non-canonical TLS mode value supplied on the command line.</param>
[Theory] [Theory]
[InlineData("Bogus")] [InlineData("Bogus")]
[InlineData("tls")] [InlineData("tls")]
@@ -92,6 +98,7 @@ public class SmtpUpdateCommandTests
Assert.NotEmpty(parse.Errors); Assert.NotEmpty(parse.Errors);
} }
/// <summary>Verifies that an update with the EWS transport produces a command carrying that transport.</summary>
[Fact] [Fact]
public void Update_WithTransportEws_ProducesCommandCarryingIt() public void Update_WithTransportEws_ProducesCommandCarryingIt()
{ {
@@ -106,6 +113,7 @@ public class SmtpUpdateCommandTests
Assert.Equal("Ews", cmd.Transport); Assert.Equal("Ews", cmd.Transport);
} }
/// <summary>Verifies that an update without a transport produces a command with a null transport.</summary>
[Fact] [Fact]
public void Update_WithoutTransport_ProducesCommandWithNullTransport() public void Update_WithoutTransport_ProducesCommandWithNullTransport()
{ {
@@ -119,6 +127,8 @@ public class SmtpUpdateCommandTests
Assert.Null(cmd.Transport); Assert.Null(cmd.Transport);
} }
/// <summary>Verifies that the transport option rejects values outside the canonical set.</summary>
/// <param name="value">The non-canonical transport value supplied on the command line.</param>
[Theory] [Theory]
[InlineData("Graph")] [InlineData("Graph")]
[InlineData("ews")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling [InlineData("ews")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
@@ -132,6 +142,7 @@ public class SmtpUpdateCommandTests
Assert.NotEmpty(parse.Errors); Assert.NotEmpty(parse.Errors);
} }
/// <summary>Verifies that the TLS mode and credentials options are not required.</summary>
[Fact] [Fact]
public void Update_TlsModeAndCredentials_AreNotRequired() public void Update_TlsModeAndCredentials_AreNotRequired()
{ {
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// MV-11: the <c>template attribute add</c> / <c>update</c> commands must support /// The <c>template attribute add</c> / <c>update</c> commands must support
/// structured multi-value (List) attributes — a new <c>--element-type</c> option, /// structured multi-value (List) attributes — a new <c>--element-type</c> option,
/// a JSON-array <c>--value</c>, client-side element-type validation, and /// a JSON-array <c>--value</c>, client-side element-type validation, and
/// <see cref="AddTemplateAttributeCommand.ElementDataType"/> / /// <see cref="AddTemplateAttributeCommand.ElementDataType"/> /
@@ -25,6 +25,7 @@ public class TemplateAttributeListTests
// ---- option surface ---- // ---- option surface ----
/// <summary>Verifies that the attribute add command exposes an element-type option.</summary>
[Fact] [Fact]
public void AttributeAdd_HasElementTypeOption() public void AttributeAdd_HasElementTypeOption()
{ {
@@ -32,6 +33,7 @@ public class TemplateAttributeListTests
Assert.Contains("--element-type", add.Options.Select(o => o.Name)); Assert.Contains("--element-type", add.Options.Select(o => o.Name));
} }
/// <summary>Verifies that the attribute update command exposes an element-type option.</summary>
[Fact] [Fact]
public void AttributeUpdate_HasElementTypeOption() public void AttributeUpdate_HasElementTypeOption()
{ {
@@ -41,6 +43,8 @@ public class TemplateAttributeListTests
// ---- client-side element-type validation (both directions) ---- // ---- client-side element-type validation (both directions) ----
/// <summary>Verifies that a List attribute with a valid scalar element type passes validation.</summary>
/// <param name="elementType">The scalar element type name under test.</param>
[Theory] [Theory]
[InlineData("String")] [InlineData("String")]
[InlineData("Int32")] [InlineData("Int32")]
@@ -55,6 +59,7 @@ public class TemplateAttributeListTests
Assert.Null(error); Assert.Null(error);
} }
/// <summary>Verifies that element-type validation for a List attribute is case-insensitive.</summary>
[Fact] [Fact]
public void ValidateElementType_ListWithValidScalar_CaseInsensitive() public void ValidateElementType_ListWithValidScalar_CaseInsensitive()
{ {
@@ -63,6 +68,7 @@ public class TemplateAttributeListTests
Assert.Null(error); Assert.Null(error);
} }
/// <summary>Verifies that a List attribute without an element type fails validation.</summary>
[Fact] [Fact]
public void ValidateElementType_ListWithoutElementType_Error() public void ValidateElementType_ListWithoutElementType_Error()
{ {
@@ -71,6 +77,7 @@ public class TemplateAttributeListTests
Assert.NotNull(error); Assert.NotNull(error);
} }
/// <summary>Verifies that a List attribute with a blank element type fails validation.</summary>
[Fact] [Fact]
public void ValidateElementType_ListWithBlankElementType_Error() public void ValidateElementType_ListWithBlankElementType_Error()
{ {
@@ -79,6 +86,7 @@ public class TemplateAttributeListTests
Assert.NotNull(error); Assert.NotNull(error);
} }
/// <summary>Verifies that a List attribute with an invalid scalar element type fails validation.</summary>
[Fact] [Fact]
public void ValidateElementType_ListWithInvalidScalar_Error() public void ValidateElementType_ListWithInvalidScalar_Error()
{ {
@@ -87,6 +95,7 @@ public class TemplateAttributeListTests
Assert.NotNull(error); Assert.NotNull(error);
} }
/// <summary>Verifies that a List attribute with a binary element type fails validation.</summary>
[Fact] [Fact]
public void ValidateElementType_ListWithBinaryScalar_Error() public void ValidateElementType_ListWithBinaryScalar_Error()
{ {
@@ -96,6 +105,7 @@ public class TemplateAttributeListTests
Assert.NotNull(error); Assert.NotNull(error);
} }
/// <summary>Verifies that a scalar attribute with an element type fails validation.</summary>
[Fact] [Fact]
public void ValidateElementType_ScalarWithElementType_Error() public void ValidateElementType_ScalarWithElementType_Error()
{ {
@@ -104,6 +114,7 @@ public class TemplateAttributeListTests
Assert.NotNull(error); Assert.NotNull(error);
} }
/// <summary>Verifies that a scalar attribute without an element type passes validation.</summary>
[Fact] [Fact]
public void ValidateElementType_ScalarWithoutElementType_Ok() public void ValidateElementType_ScalarWithoutElementType_Ok()
{ {
@@ -114,6 +125,7 @@ public class TemplateAttributeListTests
// ---- payload wiring: the raw JSON value + ElementDataType flow into the command ---- // ---- payload wiring: the raw JSON value + ElementDataType flow into the command ----
/// <summary>Verifies that building an add command for a List attribute carries the element type and the raw JSON value.</summary>
[Fact] [Fact]
public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue() public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
{ {
@@ -134,6 +146,7 @@ public class TemplateAttributeListTests
Assert.Equal("""["WO-1","WO-2"]""", cmd.Value); Assert.Equal("""["WO-1","WO-2"]""", cmd.Value);
} }
/// <summary>Verifies that building an update command for a List attribute carries the element type and the raw JSON value.</summary>
[Fact] [Fact]
public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue() public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
{ {
@@ -153,6 +166,7 @@ public class TemplateAttributeListTests
Assert.Equal("""["A","B"]""", cmd.Value); Assert.Equal("""["A","B"]""", cmd.Value);
} }
/// <summary>Verifies that building an add command for a scalar attribute leaves the element type null.</summary>
[Fact] [Fact]
public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull() public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull()
{ {
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// #54: <c>template script add/update</c> must expose <c>--min-time-between-runs</c> /// <c>template script add/update</c> must expose <c>--min-time-between-runs</c>
/// (a duration with ms/s/min units) and <c>--execution-timeout-seconds</c> (an int) /// (a duration with ms/s/min units) and <c>--execution-timeout-seconds</c> (an int)
/// so the per-script throttle/re-fire interval and the execution-timeout override — /// so the per-script throttle/re-fire interval and the execution-timeout override —
/// previously settable only via Transport bundle import — are CLI-authorable. These /// previously settable only via Transport bundle import — are CLI-authorable. These
@@ -24,6 +24,7 @@ public class TemplateScriptTimingTests
// ---- option surface ---- // ---- option surface ----
/// <summary>Verifies that <c>template script add</c> exposes the timing options.</summary>
[Fact] [Fact]
public void ScriptAdd_HasTimingOptions() public void ScriptAdd_HasTimingOptions()
{ {
@@ -33,6 +34,7 @@ public class TemplateScriptTimingTests
Assert.Contains("--execution-timeout-seconds", names); Assert.Contains("--execution-timeout-seconds", names);
} }
/// <summary>Verifies that <c>template script update</c> exposes the timing options.</summary>
[Fact] [Fact]
public void ScriptUpdate_HasTimingOptions() public void ScriptUpdate_HasTimingOptions()
{ {
@@ -44,6 +46,7 @@ public class TemplateScriptTimingTests
// ---- --min-time-between-runs parsing ---- // ---- --min-time-between-runs parsing ----
/// <summary>Verifies that an absent minimum-time value parses as unset with no error.</summary>
[Fact] [Fact]
public void MinTime_Absent_IsUnsetNoError() public void MinTime_Absent_IsUnsetNoError()
{ {
@@ -52,6 +55,7 @@ public class TemplateScriptTimingTests
Assert.Null(err); Assert.Null(err);
} }
/// <summary>Verifies that a blank minimum-time value parses as unset with no error.</summary>
[Fact] [Fact]
public void MinTime_Blank_IsUnsetNoError() public void MinTime_Blank_IsUnsetNoError()
{ {
@@ -60,6 +64,9 @@ public class TemplateScriptTimingTests
Assert.Null(err); Assert.Null(err);
} }
/// <summary>Verifies that a millisecond duration string is parsed into the expected time span.</summary>
/// <param name="value">The duration string with a millisecond suffix.</param>
/// <param name="expectedMs">The expected number of milliseconds.</param>
[Theory] [Theory]
[InlineData("500ms", 500)] [InlineData("500ms", 500)]
[InlineData("250MS", 250)] [InlineData("250MS", 250)]
@@ -70,6 +77,8 @@ public class TemplateScriptTimingTests
Assert.Equal(TimeSpan.FromMilliseconds(expectedMs), d); Assert.Equal(TimeSpan.FromMilliseconds(expectedMs), d);
} }
/// <summary>Verifies that a seconds value, including a bare number, is parsed as seconds.</summary>
/// <param name="value">The duration string to parse.</param>
[Theory] [Theory]
[InlineData("5")] // bare number → seconds [InlineData("5")] // bare number → seconds
[InlineData("5s")] [InlineData("5s")]
@@ -82,6 +91,8 @@ public class TemplateScriptTimingTests
Assert.Equal(TimeSpan.FromSeconds(5), d); Assert.Equal(TimeSpan.FromSeconds(5), d);
} }
/// <summary>Verifies that a minutes duration string is parsed as minutes.</summary>
/// <param name="value">The duration string with a minutes suffix.</param>
[Theory] [Theory]
[InlineData("2m")] [InlineData("2m")]
[InlineData("2min")] [InlineData("2min")]
@@ -93,6 +104,8 @@ public class TemplateScriptTimingTests
Assert.Equal(TimeSpan.FromMinutes(2), d); Assert.Equal(TimeSpan.FromMinutes(2), d);
} }
/// <summary>Verifies that a zero duration is treated as unset, meaning no throttle.</summary>
/// <param name="value">A zero duration string.</param>
[Theory] [Theory]
[InlineData("0")] [InlineData("0")]
[InlineData("0s")] [InlineData("0s")]
@@ -105,6 +118,8 @@ public class TemplateScriptTimingTests
Assert.Null(d); Assert.Null(d);
} }
/// <summary>Verifies that an invalid duration string is rejected with an error.</summary>
/// <param name="value">The invalid duration string.</param>
[Theory] [Theory]
[InlineData("abc")] [InlineData("abc")]
[InlineData("5h")] // hours not supported [InlineData("5h")] // hours not supported
@@ -122,6 +137,7 @@ public class TemplateScriptTimingTests
/// An absurdly large value (16-digit millisecond count) would overflow long/TimeSpan /// An absurdly large value (16-digit millisecond count) would overflow long/TimeSpan
/// without the overflow guard. The parser must return false cleanly — not throw. /// without the overflow guard. The parser must return false cleanly — not throw.
/// </summary> /// </summary>
/// <param name="value">An absurdly large duration string in milliseconds, seconds or minutes.</param>
[Theory] [Theory]
[InlineData("9999999999999999ms")] // 16-digit ms: far exceeds TimeSpan.MaxValue (~922337203685477ms) [InlineData("9999999999999999ms")] // 16-digit ms: far exceeds TimeSpan.MaxValue (~922337203685477ms)
[InlineData("9999999999999999s")] // 16-digit seconds: even larger when scaled [InlineData("9999999999999999s")] // 16-digit seconds: even larger when scaled
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// #257: <c>--trigger-kind</c> is written into the trigger-config JSON by /// <c>--trigger-kind</c> is written into the trigger-config JSON by
/// <see cref="ZB.MOM.WW.ScadaBridge.CLI.TriggerConfigJson.InjectAnalysisKind"/>, which /// <see cref="ZB.MOM.WW.ScadaBridge.CLI.TriggerConfigJson.InjectAnalysisKind"/>, which
/// returns null (dropping the kind) when no <c>--trigger-config</c> is supplied. Passing /// returns null (dropping the kind) when no <c>--trigger-config</c> is supplied. Passing
/// <c>--trigger-kind</c> without <c>--trigger-config</c> was therefore a silent no-op. /// <c>--trigger-kind</c> without <c>--trigger-config</c> was therefore a silent no-op.
@@ -12,18 +12,21 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// </summary> /// </summary>
public class TemplateTriggerKindIgnoredTests public class TemplateTriggerKindIgnoredTests
{ {
/// <summary>Verifies that a trigger kind supplied without a trigger config is reported as ignored.</summary>
[Fact] [Fact]
public void KindWithoutConfig_WillBeIgnored() public void KindWithoutConfig_WillBeIgnored()
{ {
Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: "strict")); Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: "strict"));
} }
/// <summary>Verifies that a trigger kind supplied with a blank trigger config is reported as ignored.</summary>
[Fact] [Fact]
public void KindWithBlankConfig_WillBeIgnored() public void KindWithBlankConfig_WillBeIgnored()
{ {
Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: " ", triggerKind: "strict")); Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: " ", triggerKind: "strict"));
} }
/// <summary>Verifies that a trigger kind supplied with a trigger config is not reported as ignored.</summary>
[Fact] [Fact]
public void KindWithConfig_NotIgnored() public void KindWithConfig_NotIgnored()
{ {
@@ -31,6 +34,7 @@ public class TemplateTriggerKindIgnoredTests
triggerConfig: "{\"expression\":\"x > 0\"}", triggerKind: "strict")); triggerConfig: "{\"expression\":\"x > 0\"}", triggerKind: "strict"));
} }
/// <summary>Verifies that omitting the trigger kind is not reported as ignored.</summary>
[Fact] [Fact]
public void NoKind_NotIgnored() public void NoKind_NotIgnored()
{ {
@@ -39,6 +43,7 @@ public class TemplateTriggerKindIgnoredTests
Assert.False(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: " ")); Assert.False(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: " "));
} }
/// <summary>Verifies that a trigger config supplied without a trigger kind is not reported as ignored.</summary>
[Fact] [Fact]
public void NoKindWithConfig_NotIgnored() public void NoKindWithConfig_NotIgnored()
{ {
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary> /// <summary>
/// M9-T28b: <c>template alarm add/update</c> and <c>template script add/update</c> /// <c>template alarm add/update</c> and <c>template script add/update</c>
/// must expose a <c>--trigger-kind</c> option (advisory|strict) that writes /// must expose a <c>--trigger-kind</c> option (advisory|strict) that writes
/// <c>"analysisKind":"Strict"</c> into the trigger-config JSON for expression /// <c>"analysisKind":"Strict"</c> into the trigger-config JSON for expression
/// triggers, matching the T28a backend contract exactly. /// triggers, matching the T28a backend contract exactly.
@@ -17,6 +17,7 @@ public class TemplateTriggerKindTests
// alarm-update / script-add / script-update ────────────────────────────── // alarm-update / script-add / script-update ──────────────────────────────
// Inject strict into an Expression config that has no prior analysisKind // Inject strict into an Expression config that has no prior analysisKind
/// <summary>Verifies that injecting the Strict analysis kind adds the analysis kind key to the trigger config.</summary>
[Fact] [Fact]
public void InjectAnalysisKind_Strict_AddsKey() public void InjectAnalysisKind_Strict_AddsKey()
{ {
@@ -29,6 +30,7 @@ public class TemplateTriggerKindTests
} }
// Inject advisory (null) into config that had Strict → key removed // Inject advisory (null) into config that had Strict → key removed
/// <summary>Verifies that injecting the Advisory analysis kind removes the analysis kind key from the trigger config.</summary>
[Fact] [Fact]
public void InjectAnalysisKind_Advisory_RemovesKey() public void InjectAnalysisKind_Advisory_RemovesKey()
{ {
@@ -41,6 +43,7 @@ public class TemplateTriggerKindTests
} }
// Inject strict into config that already had Strict → still Strict (idempotent) // Inject strict into config that already had Strict → still Strict (idempotent)
/// <summary>Verifies that injecting the Strict analysis kind twice yields the same config as injecting it once.</summary>
[Fact] [Fact]
public void InjectAnalysisKind_Strict_Idempotent() public void InjectAnalysisKind_Strict_Idempotent()
{ {
@@ -53,6 +56,7 @@ public class TemplateTriggerKindTests
} }
// Null config → returns null (no config to inject into) // Null config → returns null (no config to inject into)
/// <summary>Verifies that injecting an analysis kind into a null config returns null.</summary>
[Fact] [Fact]
public void InjectAnalysisKind_NullConfig_ReturnsNull() public void InjectAnalysisKind_NullConfig_ReturnsNull()
{ {
@@ -61,6 +65,7 @@ public class TemplateTriggerKindTests
} }
// Case-insensitive: "STRICT" → canonical "Strict" // Case-insensitive: "STRICT" → canonical "Strict"
/// <summary>Verifies that the Strict analysis kind is recognized regardless of letter casing.</summary>
[Fact] [Fact]
public void InjectAnalysisKind_Strict_CaseInsensitive() public void InjectAnalysisKind_Strict_CaseInsensitive()
{ {
@@ -73,6 +78,7 @@ public class TemplateTriggerKindTests
} }
// Other keys preserved when injecting // Other keys preserved when injecting
/// <summary>Verifies that injecting an analysis kind leaves the other trigger config keys unchanged.</summary>
[Fact] [Fact]
public void InjectAnalysisKind_OtherKeysPreserved() public void InjectAnalysisKind_OtherKeysPreserved()
{ {
@@ -88,6 +94,7 @@ public class TemplateTriggerKindTests
// ── Option surface: alarm add + update have --trigger-kind ─────────────── // ── Option surface: alarm add + update have --trigger-kind ───────────────
/// <summary>Verifies that the alarm add command exposes a trigger-kind option.</summary>
[Fact] [Fact]
public void AlarmAdd_HasTriggerKindOption() public void AlarmAdd_HasTriggerKindOption()
{ {
@@ -95,6 +102,7 @@ public class TemplateTriggerKindTests
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name)); Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
} }
/// <summary>Verifies that the alarm update command exposes a trigger-kind option.</summary>
[Fact] [Fact]
public void AlarmUpdate_HasTriggerKindOption() public void AlarmUpdate_HasTriggerKindOption()
{ {
@@ -104,6 +112,7 @@ public class TemplateTriggerKindTests
// ── Option surface: script add + update have --trigger-kind ───────────── // ── Option surface: script add + update have --trigger-kind ─────────────
/// <summary>Verifies that the script add command exposes a trigger-kind option.</summary>
[Fact] [Fact]
public void ScriptAdd_HasTriggerKindOption() public void ScriptAdd_HasTriggerKindOption()
{ {
@@ -111,6 +120,7 @@ public class TemplateTriggerKindTests
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name)); Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
} }
/// <summary>Verifies that the script update command exposes a trigger-kind option.</summary>
[Fact] [Fact]
public void ScriptUpdate_HasTriggerKindOption() public void ScriptUpdate_HasTriggerKindOption()
{ {
@@ -120,6 +130,7 @@ public class TemplateTriggerKindTests
// ── AlarmTriggerConfigJson.Build — analysisKind injection ──────────────── // ── AlarmTriggerConfigJson.Build — analysisKind injection ────────────────
/// <summary>Verifies that building an expression alarm config with the Strict kind injects an analysis kind of Strict.</summary>
[Fact] [Fact]
public void AlarmConfigBuild_Expression_StrictKind_InjectsAnalysisKindStrict() public void AlarmConfigBuild_Expression_StrictKind_InjectsAnalysisKindStrict()
{ {
@@ -139,6 +150,7 @@ public class TemplateTriggerKindTests
Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString()); Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString());
} }
/// <summary>Verifies that building an expression alarm config with the Advisory kind omits the analysis kind key.</summary>
[Fact] [Fact]
public void AlarmConfigBuild_Expression_AdvisoryKind_OmitsAnalysisKindKey() public void AlarmConfigBuild_Expression_AdvisoryKind_OmitsAnalysisKindKey()
{ {
@@ -158,6 +170,7 @@ public class TemplateTriggerKindTests
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
} }
/// <summary>Verifies that building an expression alarm config with no kind omits the analysis kind key.</summary>
[Fact] [Fact]
public void AlarmConfigBuild_Expression_NullKind_OmitsAnalysisKindKey() public void AlarmConfigBuild_Expression_NullKind_OmitsAnalysisKindKey()
{ {
@@ -177,6 +190,7 @@ public class TemplateTriggerKindTests
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
} }
/// <summary>Verifies that a non-expression alarm config does not emit an analysis kind even when the Strict kind is given.</summary>
[Fact] [Fact]
public void AlarmConfigBuild_NonExpression_StrictKind_DoesNotEmitAnalysisKind() public void AlarmConfigBuild_NonExpression_StrictKind_DoesNotEmitAnalysisKind()
{ {
@@ -196,6 +210,7 @@ public class TemplateTriggerKindTests
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
} }
/// <summary>Verifies that the Strict kind is recognized case-insensitively when building an expression alarm config.</summary>
[Fact] [Fact]
public void AlarmConfigBuild_Expression_StrictKind_CaseInsensitive() public void AlarmConfigBuild_Expression_StrictKind_CaseInsensitive()
{ {
@@ -45,6 +45,8 @@ public class ConnectionBrowseCommandsTests
return (root, captured); return (root, captured);
} }
/// <summary>Verifies that the browse command builds a browse-node command addressed by site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier() public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier()
{ {
@@ -61,6 +63,8 @@ public class ConnectionBrowseCommandsTests
Assert.Equal("ns=2;s=X", cmd.ParentNodeId); Assert.Equal("ns=2;s=X", cmd.ParentNodeId);
} }
/// <summary>Verifies that browsing without a node id starts from the address-space root.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Browse_WithoutNodeId_BrowsesFromRoot() public async Task Browse_WithoutNodeId_BrowsesFromRoot()
{ {
@@ -76,6 +80,8 @@ public class ConnectionBrowseCommandsTests
Assert.Null(cmd.ParentNodeId); Assert.Null(cmd.ParentNodeId);
} }
/// <summary>Verifies that the search command builds a search-address-space command addressed by site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier() public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier()
{ {
@@ -98,6 +104,8 @@ public class ConnectionBrowseCommandsTests
Assert.Equal(50, cmd.MaxResults); Assert.Equal(50, cmd.MaxResults);
} }
/// <summary>Verifies that verify-endpoint reads the configuration JSON from a file and addresses the site by identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task VerifyEndpoint_ReadsConfigJsonFromFile_WithSiteIdentifier() public async Task VerifyEndpoint_ReadsConfigJsonFromFile_WithSiteIdentifier()
{ {
@@ -128,6 +136,8 @@ public class ConnectionBrowseCommandsTests
} }
} }
/// <summary>Verifies that certs list builds a list-server-certs command addressed by site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier() public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier()
{ {
@@ -142,6 +152,8 @@ public class ConnectionBrowseCommandsTests
Assert.Equal("SITE1", cmd.SiteIdentifier); Assert.Equal("SITE1", cmd.SiteIdentifier);
} }
/// <summary>Verifies that certs trust base64-encodes the DER certificate file and addresses the site by identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CertsTrust_Base64EncodesDerFile_WithSiteIdentifier() public async Task CertsTrust_Base64EncodesDerFile_WithSiteIdentifier()
{ {
@@ -173,6 +185,8 @@ public class ConnectionBrowseCommandsTests
} }
} }
/// <summary>Verifies that certs remove builds a remove-server-cert command addressed by site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier() public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier()
{ {
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary> /// <summary>
/// Regression tests for CLI-006 — credentials could only be supplied via the /// Regression tests for environment-based credential supply — credentials could only be supplied via the
/// <c>--password</c> command-line option, which leaks into process listings and /// <c>--password</c> command-line option, which leaks into process listings and
/// shell history. A <c>SCADABRIDGE_PASSWORD</c> / <c>SCADABRIDGE_USERNAME</c> environment /// shell history. A <c>SCADABRIDGE_PASSWORD</c> / <c>SCADABRIDGE_USERNAME</c> environment
/// fallback gives CI/CD a safer alternative. /// fallback gives CI/CD a safer alternative.
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Environment")] [Collection("Environment")]
public class CredentialResolutionTests public class CredentialResolutionTests
{ {
/// <summary>Verifies that the password is loaded from the environment variable.</summary>
[Fact] [Fact]
public void Load_Password_FromEnvironment() public void Load_Password_FromEnvironment()
{ {
@@ -29,6 +30,7 @@ public class CredentialResolutionTests
} }
} }
/// <summary>Verifies that the username is loaded from the environment variable.</summary>
[Fact] [Fact]
public void Load_Username_FromEnvironment() public void Load_Username_FromEnvironment()
{ {
@@ -47,6 +49,7 @@ public class CredentialResolutionTests
} }
} }
/// <summary>Verifies that loading with no credential environment variables leaves the credentials null.</summary>
[Fact] [Fact]
public void Load_NoCredentialEnvVars_LeavesCredentialsNull() public void Load_NoCredentialEnvVars_LeavesCredentialsNull()
{ {
@@ -5,13 +5,14 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary> /// <summary>
/// Regression tests for the testable pieces of <c>DebugCommands.StreamDebugAsync</c>: /// Regression tests for the testable pieces of <c>DebugCommands.StreamDebugAsync</c>:
/// CLI-010 (Ctrl+C during connect misreported as a connection failure) and /// Ctrl+C during connect being misreported as a connection failure, and
/// CLI-012 (non-deterministic exit code after stream termination). /// non-deterministic exit codes after stream termination.
/// </summary> /// </summary>
public class DebugStreamTests public class DebugStreamTests
{ {
// --- CLI-010: connect-failure classification -------------------------------------- // --- connect-failure classification --------------------------------------
/// <summary>Verifies that an <see cref="OperationCanceledException"/> raised after cancellation was requested is treated as a graceful cancellation.</summary>
[Fact] [Fact]
public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation() public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation()
{ {
@@ -24,6 +25,7 @@ public class DebugStreamTests
Assert.Equal(0, result.ExitCode); Assert.Equal(0, result.ExitCode);
} }
/// <summary>Verifies that a <see cref="TaskCanceledException"/> raised after cancellation was requested is treated as a cancellation.</summary>
[Fact] [Fact]
public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation() public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation()
{ {
@@ -34,6 +36,7 @@ public class DebugStreamTests
Assert.Equal(0, result.ExitCode); Assert.Equal(0, result.ExitCode);
} }
/// <summary>Verifies that a genuine connection exception is classified as a connection failure with exit code 1.</summary>
[Fact] [Fact]
public void ClassifyConnectFailure_RealException_IsConnectionFailure() public void ClassifyConnectFailure_RealException_IsConnectionFailure()
{ {
@@ -44,6 +47,7 @@ public class DebugStreamTests
Assert.Equal(1, result.ExitCode); Assert.Equal(1, result.ExitCode);
} }
/// <summary>Verifies that a cancellation exception without a user cancel request is classified as a connection failure.</summary>
[Fact] [Fact]
public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure() public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure()
{ {
@@ -56,8 +60,10 @@ public class DebugStreamTests
Assert.Equal(1, result.ExitCode); Assert.Equal(1, result.ExitCode);
} }
// --- CLI-012: deterministic exit-code resolution ---------------------------------- // --- deterministic exit-code resolution ----------------------------------
/// <summary>Verifies that a result already set by stream termination is preferred when resolving the exit code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult() public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult()
{ {
@@ -70,6 +76,8 @@ public class DebugStreamTests
Assert.Equal(1, code); Assert.Equal(1, code);
} }
/// <summary>Verifies that the exit code resolves to zero when no termination result was ever set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero() public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero()
{ {
@@ -81,6 +89,8 @@ public class DebugStreamTests
Assert.Equal(0, code); Assert.Equal(0, code);
} }
/// <summary>Verifies that a result arriving during the grace period is observed when resolving the exit code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved() public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved()
{ {
@@ -22,6 +22,11 @@ public class DeployArtifactsExitCodeTests
"{\"siteId\":\"site-b\",\"siteName\":\"B\",\"success\":false,\"errorMessage\":\"Secret 'k' ... missing\"}]," + "{\"siteId\":\"site-b\",\"siteName\":\"B\",\"success\":false,\"errorMessage\":\"Secret 'k' ... missing\"}]," +
"\"successCount\":1,\"failureCount\":1}"; "\"successCount\":1,\"failureCount\":1}";
/// <summary>Verifies that the artifact deploy command exits with a non-zero code exactly when a site failed or the response is unrecognized.</summary>
/// <param name="group">The deploy command group under test (<c>site</c> or <c>deploy</c>).</param>
/// <param name="response">The JSON response the management endpoint returns.</param>
/// <param name="expected">The expected process exit code.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("site", AllSucceeded, 0)] [InlineData("site", AllSucceeded, 0)]
[InlineData("site", OneFailed, 1)] [InlineData("site", OneFailed, 1)]
@@ -8,6 +8,10 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
public class DeploySiteExitCodeTests public class DeploySiteExitCodeTests
{ {
/// <summary>Verifies that the actual deploy command returns an exit code derived from the success and failure counts in the response.</summary>
/// <param name="response">The JSON response body served by the fake management endpoint.</param>
/// <param name="expected">The expected process exit code.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("{\"successCount\":2,\"failureCount\":0}", 0)] [InlineData("{\"successCount\":2,\"failureCount\":0}", 0)]
[InlineData("{\"successCount\":1,\"failureCount\":1}", 1)] [InlineData("{\"successCount\":1,\"failureCount\":1}", 1)]
@@ -10,6 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")] [Collection("Console")]
public class DeploymentDeadlineParityTests public class DeploymentDeadlineParityTests
{ {
/// <summary>Verifies that every single-deployment CLI alias waits for a server reply beyond the ordinary 30-second deadline.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline() public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline()
{ {
@@ -35,6 +35,7 @@ public class DeploymentStatusTableProjectionTests
PageSize: rowCount), PageSize: rowCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
/// <summary>Verifies that projection: unwraps the envelope to a row array.</summary>
[Fact] [Fact]
public void Projection_UnwrapsTheEnvelopeToARowArray() public void Projection_UnwrapsTheEnvelopeToARowArray()
{ {
@@ -47,6 +48,7 @@ public class DeploymentStatusTableProjectionTests
Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString()); Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString());
} }
/// <summary>Verifies that projection, when page is a strict subset, appends a total note.</summary>
[Fact] [Fact]
public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote() public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote()
{ {
@@ -59,6 +61,7 @@ public class DeploymentStatusTableProjectionTests
Assert.Contains("--page-size", note); Assert.Contains("--page-size", note);
} }
/// <summary>Verifies that projection, when page is the whole result, emits no note column.</summary>
[Fact] [Fact]
public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn() public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn()
{ {
@@ -69,6 +72,7 @@ public class DeploymentStatusTableProjectionTests
e => Assert.False(e.TryGetProperty("note", out _))); e => Assert.False(e.TryGetProperty("note", out _)));
} }
/// <summary>Verifies that projection, when accepts a bare row array, from a pre-envelope server.</summary>
[Fact] [Fact]
public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer() public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer()
{ {
@@ -79,6 +83,8 @@ public class DeploymentStatusTableProjectionTests
.GetProperty("deploymentId").GetString()); .GetProperty("deploymentId").GetString());
} }
/// <summary>Verifies that projection: leaves unrecognised bodies untouched.</summary>
/// <param name="body">The body.</param>
[Theory] [Theory]
[InlineData("not json at all")] [InlineData("not json at all")]
[InlineData("""{"error":"boom"}""")] [InlineData("""{"error":"boom"}""")]
@@ -24,6 +24,11 @@ public class EditingParityTests
return root; return root;
} }
/// <summary>Verifies that each command sends the exact management envelope with the expected command name and payload.</summary>
/// <param name="arguments">The command-line arguments to run.</param>
/// <param name="command">The expected management command name.</param>
/// <param name="payload">The expected JSON payload.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")] [InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")]
[InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")] [InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")]
@@ -74,6 +79,8 @@ public class EditingParityTests
finally { listener.Stop(); } finally { listener.Stop(); }
} }
/// <summary>Verifies that missing or malformed arguments are rejected.</summary>
/// <param name="arguments">The invalid command-line arguments to run.</param>
[Theory] [Theory]
[InlineData("template resolved-members")] [InlineData("template resolved-members")]
[InlineData("template move --id 12 --folder-id invalid")] [InlineData("template move --id 12 --folder-id invalid")]
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary> /// <summary>
/// Regression tests for CLI-009 — the design doc defines exit code 2 as "authorization /// Regression tests for the exit-code contract: the design doc defines exit code 2 as "authorization
/// failure". The previous implementation keyed exit 2 solely off HTTP 403, so an /// failure". The previous implementation keyed exit 2 solely off HTTP 403, so an
/// authorization failure the server signalled with an error <c>code</c> of /// authorization failure the server signalled with an error <c>code</c> of
/// <c>UNAUTHORIZED</c>/<c>FORBIDDEN</c> but a different HTTP status was misreported as a /// <c>UNAUTHORIZED</c>/<c>FORBIDDEN</c> but a different HTTP status was misreported as a
@@ -27,12 +27,15 @@ public class ExitCodeTests
} }
} }
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
[Fact] [Fact]
public void HandleResponse_Http403_ReturnsTwo() public void HandleResponse_Http403_ReturnsTwo()
{ {
Assert.Equal(2, HandleQuietly(new ManagementResponse(403, null, "Forbidden", "FORBIDDEN"))); Assert.Equal(2, HandleQuietly(new ManagementResponse(403, null, "Forbidden", "FORBIDDEN")));
} }
/// <summary>Verifies that an authorization error code with a status other than 403 still returns exit code 2.</summary>
/// <param name="code">The error code returned by the server.</param>
[Theory] [Theory]
[InlineData("UNAUTHORIZED")] [InlineData("UNAUTHORIZED")]
[InlineData("FORBIDDEN")] [InlineData("FORBIDDEN")]
@@ -40,16 +43,18 @@ public class ExitCodeTests
public void HandleResponse_AuthorizationCode_NonForbiddenStatus_ReturnsTwo(string code) public void HandleResponse_AuthorizationCode_NonForbiddenStatus_ReturnsTwo(string code)
{ {
// The server signalled an authorization failure via the error code but with a // The server signalled an authorization failure via the error code but with a
// non-403 HTTP status; per the documented exit-code table this is still exit 2. // HTTP status other than 403 status; per the documented exit-code table this is still exit 2.
Assert.Equal(2, HandleQuietly(new ManagementResponse(400, null, "Access denied", code))); Assert.Equal(2, HandleQuietly(new ManagementResponse(400, null, "Access denied", code)));
} }
/// <summary>Verifies that a generic error returns exit code 1.</summary>
[Fact] [Fact]
public void HandleResponse_GenericError_ReturnsOne() public void HandleResponse_GenericError_ReturnsOne()
{ {
Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT"))); Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT")));
} }
/// <summary>Verifies that an authentication failure returns exit code 1.</summary>
[Fact] [Fact]
public void HandleResponse_AuthenticationFailure_ReturnsOne() public void HandleResponse_AuthenticationFailure_ReturnsOne()
{ {

Some files were not shown because too many files have changed in this diff Show More