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:
@@ -179,7 +179,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
||||
/// cleanly on another. Free-text columns are run through
|
||||
/// <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
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="evt">The audit event view to format as a CSV row.</param>
|
||||
@@ -228,7 +228,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
||||
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
|
||||
// formula/DDE trigger character renders as inert text in Excel.
|
||||
if (freeText && NeedsFormulaNeutralization(value))
|
||||
|
||||
@@ -708,7 +708,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// the cap is reached the periodic reconcile tick is doing the same work anyway, so no
|
||||
/// 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
|
||||
/// 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
|
||||
/// timestamp (a <c>default</c> sentinel would display as year-0001).
|
||||
/// timestamp (a <c>default</c> sentinel would display as year 0001).
|
||||
/// </summary>
|
||||
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.Credentials is not null) config.Credentials = cmd.Credentials;
|
||||
// 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.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E (M2 Task E1) DI surface tests for <c>AddAuditLog</c>. M1 shipped
|
||||
/// the options-only scaffold; M2 extends it with the site writer chain
|
||||
/// DI surface tests for <c>AddAuditLog</c>. The initial version was an
|
||||
/// options-only scaffold; it was then extended with the site writer chain
|
||||
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
||||
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
|
||||
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
|
||||
@@ -41,6 +41,8 @@ public class AddAuditLogTests
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddAuditLog registers the audit log options.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_RegistersAuditLogOptions()
|
||||
{
|
||||
@@ -52,6 +54,8 @@ public class AddAuditLogTests
|
||||
Assert.NotNull(opts!.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddAuditLog throws when the service collection is null.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_NullServices_Throws()
|
||||
{
|
||||
@@ -61,6 +65,8 @@ public class AddAuditLogTests
|
||||
() => ServiceCollectionExtensions.AddAuditLog(null!, config));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddAuditLog throws when the configuration is null.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_NullConfig_Throws()
|
||||
{
|
||||
@@ -70,8 +76,10 @@ public class AddAuditLogTests
|
||||
() => 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]
|
||||
public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
|
||||
{
|
||||
@@ -90,6 +98,8 @@ public class AddAuditLogTests
|
||||
Assert.Same(writer, provider.GetService<SqliteAuditWriter>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddAuditLog registers IAuditWriter as the fallback audit writer.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
|
||||
{
|
||||
@@ -104,6 +114,8 @@ public class AddAuditLogTests
|
||||
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]
|
||||
public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
|
||||
{
|
||||
@@ -123,6 +135,8 @@ public class AddAuditLogTests
|
||||
Assert.Same(writer, queue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddAuditLog registers the ring-buffer fallback as a singleton.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_RingBufferFallback_Singleton()
|
||||
{
|
||||
@@ -136,6 +150,8 @@ public class AddAuditLogTests
|
||||
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]
|
||||
public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
|
||||
{
|
||||
@@ -149,6 +165,8 @@ public class AddAuditLogTests
|
||||
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddAuditLog registers a no-op site stream audit client by default.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
|
||||
{
|
||||
@@ -162,8 +180,10 @@ public class AddAuditLogTests
|
||||
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]
|
||||
public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter()
|
||||
{
|
||||
@@ -177,6 +197,8 @@ public class AddAuditLogTests
|
||||
Assert.IsType<CentralAuditWriter>(writer);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the ICentralAuditWriter registration is a singleton.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_ICentralAuditWriter_IsSingleton()
|
||||
{
|
||||
@@ -190,6 +212,8 @@ public class AddAuditLogTests
|
||||
Assert.Same(w1, w2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the SQLite writer options bind from configuration and round-trip.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
|
||||
{
|
||||
@@ -208,6 +232,8 @@ public class AddAuditLogTests
|
||||
Assert.Equal(75, opts.FlushIntervalMs);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the site telemetry options bind from configuration and round-trip.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
|
||||
{
|
||||
@@ -224,8 +250,10 @@ public class AddAuditLogTests
|
||||
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]
|
||||
public void AddAuditLogHealthMetricsBridge_Swaps_FailureCounter_To_HealthMetrics_Implementation()
|
||||
{
|
||||
@@ -251,6 +279,8 @@ public class AddAuditLogTests
|
||||
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]
|
||||
public void AddAuditLogHealthMetricsBridge_Without_HealthMonitoring_Still_Resolves_But_Errors_On_Use()
|
||||
{
|
||||
@@ -276,10 +306,12 @@ public class AddAuditLogTests
|
||||
() => 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]
|
||||
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
|
||||
// SiteAuditBacklogReporter on the same SQLite file. The helper must
|
||||
// be a no-op on the second call — exactly one hosted-service
|
||||
|
||||
+63
-8
@@ -19,9 +19,9 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>'s M3 combined-
|
||||
/// telemetry dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
|
||||
/// as the M1 + M2 repository tests so the actor exercises real
|
||||
/// Tests for <see cref="AuditLogIngestActor"/>'s combined-telemetry
|
||||
/// dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
|
||||
/// as the audit-log and site-call repository tests so the actor exercises real
|
||||
/// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> +
|
||||
/// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL
|
||||
/// database. The transaction commits or rolls back inside one
|
||||
@@ -31,6 +31,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||
public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -125,6 +127,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
serviceProvider,
|
||||
NullLogger<AuditLogIngestActor>.Instance)));
|
||||
|
||||
/// <summary>Verifies that one cached-telemetry packet writes both an audit row and a site-call row and acknowledges the event id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId()
|
||||
{
|
||||
@@ -156,6 +160,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
Assert.Equal(siteCall.Status, siteCallRow!.Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a duplicate event id with an unchanged status is a no-op that leaves the row counts unchanged and still acknowledges the id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId()
|
||||
{
|
||||
@@ -195,6 +201,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
Assert.Equal(1, siteCallCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a duplicate event id carrying an advanced site-call status updates the existing site-call row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall()
|
||||
{
|
||||
@@ -206,7 +214,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
var sp = BuildServiceProvider();
|
||||
var actor = CreateActor(sp);
|
||||
|
||||
// 1st packet — Submitted (audit EventId #1, SiteCalls Status=Submitted).
|
||||
// 1st packet — Submitted (first audit EventId, SiteCalls Status=Submitted).
|
||||
var (auditSubmit, siteCallSubmit) = NewEntry(
|
||||
siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted);
|
||||
actor.Tell(
|
||||
@@ -214,7 +222,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
TestActor);
|
||||
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
|
||||
|
||||
// 2nd packet — Attempted with retry count 1 (audit EventId #2,
|
||||
// 2nd packet — Attempted with retry count 1 (second audit EventId,
|
||||
// SiteCalls Status=Attempted — monotonic upsert wins). Same
|
||||
// TrackedOperationId throughout.
|
||||
var (auditAttempt, siteCallAttempt) = NewEntry(
|
||||
@@ -242,6 +250,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
Assert.Equal(1, siteCallRow.RetryCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when the site-call upsert throws after the audit insert succeeds, both writes are rolled back and no orphan audit row remains.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow()
|
||||
{
|
||||
@@ -273,6 +283,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
Assert.Null(siteCallRow);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that five cached-telemetry packets are each persisted separately and all are acknowledged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked()
|
||||
{
|
||||
@@ -303,6 +315,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
Assert.Equal(5, siteCallCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a packet failing after an earlier packet succeeded leaves the first one committed and lets the batch continue.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues()
|
||||
{
|
||||
@@ -348,7 +362,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// arch-review 2026-08-19 P5-1: the per-entry fallback loop (used when the
|
||||
/// The per-entry fallback loop (used when the
|
||||
/// batch fast path fails) must run under its own short
|
||||
/// <see cref="AuditLogIngestActor.IngestFallbackBudget"/>, not an unbounded
|
||||
/// wait — otherwise a single slow/wedged downstream call can freeze this
|
||||
@@ -368,6 +382,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
/// <c>ExpectMsg</c> below would time out and fail, which is the fail-unfixed
|
||||
/// proof for this fix.
|
||||
/// </remarks>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_CachedTelemetry_PerEntryFallbackHangs_BudgetBoundsWait_RemainderSkipped()
|
||||
{
|
||||
@@ -430,38 +445,51 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
/// </summary>
|
||||
private sealed class BatchFailingAuditRepo : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
private readonly IAuditLogRepository _inner;
|
||||
/// <summary>Initializes the double with the real repository that every non-failing member delegates to.</summary>
|
||||
/// <param name="inner">The real repository to delegate to.</param>
|
||||
public BatchFailingAuditRepo(IAuditLogRepository inner) { _inner = inner; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> InsertManyIfNotExistsAsync(
|
||||
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("simulated batch ingest failure — forces the per-entry fallback");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||
_inner.InsertIfNotExistsAsync(evt, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
_inner.QueryAsync(filter, paging, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(
|
||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||
_inner.GetExecutionTreeAsync(executionId, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
_inner.GetDistinctSourceNodesAsync(ct);
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
|
||||
@@ -471,15 +499,17 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
/// Test double — <see cref="UpsertAsync"/> hangs on
|
||||
/// <c>Task.Delay(Timeout.InfiniteTimeSpan, ct)</c> until the caller's token
|
||||
/// cancels, recording the call count and the token it was handed. Used to
|
||||
/// prove the per-entry fallback loop's own budget bounds the wait (arch-review
|
||||
/// 2026-08-19 P5-1) rather than blocking forever on a wedged downstream call.
|
||||
/// prove the per-entry fallback loop's own budget bounds the wait rather blocking forever on a wedged downstream call.
|
||||
/// </summary>
|
||||
private sealed class HangingSiteCallRepo : ISiteCallAuditRepository
|
||||
{
|
||||
private int _callCount;
|
||||
/// <summary>Gets the number of times <see cref="UpsertAsync"/> has been invoked.</summary>
|
||||
public int CallCount => _callCount;
|
||||
/// <summary>Gets the cancellation token most recently passed to <see cref="UpsertAsync"/>.</summary>
|
||||
public CancellationToken? ReceivedToken { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
@@ -487,19 +517,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
@@ -513,22 +549,31 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository
|
||||
{
|
||||
private readonly ISiteCallAuditRepository _inner;
|
||||
/// <summary>Initializes the double with the real repository that read members delegate to.</summary>
|
||||
/// <param name="inner">The real repository to delegate to.</param>
|
||||
public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; }
|
||||
/// <inheritdoc />
|
||||
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("simulated SiteCalls upsert failure");
|
||||
/// <inheritdoc />
|
||||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||
_inner.GetAsync(id, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||
_inner.QueryAsync(filter, paging, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
@@ -543,11 +588,15 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
{
|
||||
private readonly ISiteCallAuditRepository _inner;
|
||||
private readonly TrackedOperationId _poisonId;
|
||||
/// <summary>Initializes the double with the real repository and the id whose upsert should fail.</summary>
|
||||
/// <param name="inner">The real repository to delegate to.</param>
|
||||
/// <param name="poisonId">The tracked operation id whose upsert throws.</param>
|
||||
public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
|
||||
{
|
||||
_inner = inner;
|
||||
_poisonId = poisonId;
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
||||
{
|
||||
if (siteCall.TrackedOperationId == _poisonId)
|
||||
@@ -556,19 +605,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
}
|
||||
return _inner.UpsertAsync(siteCall, ct);
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||
_inner.GetAsync(id, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||
_inner.QueryAsync(filter, paging, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>. Uses the same
|
||||
/// <see cref="MsSqlMigrationFixture"/> as the M1 repository tests so the actor
|
||||
/// <see cref="MsSqlMigrationFixture"/> as the repository tests so the actor
|
||||
/// exercises real <see cref="AuditLogRepository.InsertIfNotExistsAsync"/>
|
||||
/// against a partitioned MSSQL schema (the only way to verify the
|
||||
/// IngestedAtUtc stamp + duplicate-key idempotency end to end).
|
||||
@@ -27,6 +27,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||
public AuditLogIngestActorTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -61,6 +63,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
NullLogger<AuditLogIngestActor>.Instance)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch of five events calls the repository five times and acknowledges all five.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5()
|
||||
{
|
||||
@@ -87,6 +91,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.Equal(5, rows.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch containing an already-existing event acknowledges every event without inserting a duplicate.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert()
|
||||
{
|
||||
@@ -126,12 +132,14 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.Equal(3, count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that duplicate event ids within one packet produce a single row and are all acknowledged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_DuplicateEventIds_WithinOnePacket_ProduceOneRow_AndAreAllAcked()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// WP2.2 writes each packet as ONE set-based statement, whose anti-semi-join
|
||||
// The repository writes each packet as ONE set-based statement, whose anti-semi-join
|
||||
// only sees already-COMMITTED rows. Two copies of an EventId inside one
|
||||
// packet would therefore both pass it and collide on the clustered PK,
|
||||
// taking the whole packet down — the repository de-duplicates first.
|
||||
@@ -161,6 +169,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.Equal(2, rows.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sending the same packet twice is idempotent across packets.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets()
|
||||
{
|
||||
@@ -194,6 +204,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.Equal(6, rows.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that overlapping packets insert only the new rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows()
|
||||
{
|
||||
@@ -225,6 +237,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.Equal(7, rows.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the actor stamps IngestedAtUtc before inserting an event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_Sets_IngestedAtUtc_Before_Insert()
|
||||
{
|
||||
@@ -257,6 +271,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when the repository throws for one event, the other four are still persisted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted()
|
||||
{
|
||||
@@ -289,10 +305,12 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.DoesNotContain(rows, r => r.EventId == poisonId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when repository resolution throws, the actor survives, replies with an empty acknowledgement, and counts the failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Receive_WhenRepositoryResolutionThrows_ActorSurvives_RepliesEmpty_CountsFailure()
|
||||
{
|
||||
// AuditLog-017 (covers the AuditLog-014 guard): the production ctor resolves the
|
||||
// The production ctor resolves the
|
||||
// scoped repository per message. If scope creation / repository resolution throws
|
||||
// (transient DI or DbContext-factory fault, pooled-context init, a resolution race
|
||||
// during host churn), the outer guard must keep the singleton ALIVE, increment the
|
||||
@@ -343,6 +361,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
/// source, so "not equal" IS "a fresh CTS", and a fresh CTS cannot be
|
||||
/// pre-cancelled by the batch.
|
||||
/// </remarks>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Receive_WhenBatchFails_PerRowFallbackUsesAFreshToken()
|
||||
{
|
||||
@@ -371,7 +390,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
/// <summary>Counts how many times the guard's catch surfaced a write failure.</summary>
|
||||
private sealed class CountingFailureCounter : ICentralAuditWriteFailureCounter
|
||||
{
|
||||
/// <summary>Gets the number of failures counted so far.</summary>
|
||||
public int Count { get; private set; }
|
||||
/// <inheritdoc />
|
||||
public void Increment() => Count++;
|
||||
}
|
||||
|
||||
@@ -382,13 +403,17 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
/// </summary>
|
||||
private sealed class TokenRecordingRepository : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
/// <summary>Gets the cancellation token handed to the set-based batch insert.</summary>
|
||||
public CancellationToken? BatchToken { get; private set; }
|
||||
|
||||
/// <summary>Gets the cancellation tokens handed to each per-row insert.</summary>
|
||||
public List<CancellationToken> RowTokens { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> InsertManyIfNotExistsAsync(
|
||||
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||
{
|
||||
@@ -396,40 +421,49 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
throw new OperationCanceledException("simulated ingest-budget expiry", ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
RowTokens.Add(ct);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(
|
||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -441,18 +475,23 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
/// </summary>
|
||||
private sealed class ThrowingRepository : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
private readonly IAuditLogRepository _inner;
|
||||
private readonly Guid _poisonId;
|
||||
|
||||
/// <summary>Initializes the double with an inner repository and the id of the event that must fail.</summary>
|
||||
/// <param name="inner">The real repository to delegate to.</param>
|
||||
/// <param name="poisonId">The event id for which inserts throw.</param>
|
||||
public ThrowingRepository(IAuditLogRepository inner, Guid poisonId)
|
||||
{
|
||||
_inner = inner;
|
||||
_poisonId = poisonId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (evt.EventId == _poisonId)
|
||||
@@ -462,33 +501,41 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
return _inner.InsertIfNotExistsAsync(evt, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
_inner.QueryAsync(filter, paging, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||
_inner.GetExecutionTreeAsync(executionId, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
_inner.GetDistinctSourceNodesAsync(ct);
|
||||
}
|
||||
|
||||
+3
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
/// </summary>
|
||||
public class AuditLogPartitionMaintenanceOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that the default options pass validation.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -17,6 +18,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero interval fails validation and names the interval property.</summary>
|
||||
[Fact]
|
||||
public void ZeroIntervalSeconds_Fails()
|
||||
{
|
||||
@@ -28,6 +30,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests
|
||||
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero look-ahead fails validation and names the look-ahead property.</summary>
|
||||
[Fact]
|
||||
public void ZeroLookaheadMonths_Fails()
|
||||
{
|
||||
|
||||
+25
-2
@@ -9,7 +9,7 @@ using Xunit;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D (#23 M6-T5) tests for <see cref="AuditLogPartitionMaintenanceService"/>.
|
||||
/// Tests for <see cref="AuditLogPartitionMaintenanceService"/>.
|
||||
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
|
||||
/// the real EF/MSSQL implementation is exercised by the
|
||||
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
|
||||
@@ -28,6 +28,7 @@ public class AuditLogPartitionMaintenanceServiceTests
|
||||
public int EnsureCallCount;
|
||||
public Exception? ThrowOnce;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref EnsureCallCount);
|
||||
@@ -39,6 +40,7 @@ public class AuditLogPartitionMaintenanceServiceTests
|
||||
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
|
||||
}
|
||||
@@ -50,12 +52,27 @@ public class AuditLogPartitionMaintenanceServiceTests
|
||||
/// </summary>
|
||||
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
|
||||
{
|
||||
/// <summary>Gets the log entries captured so far.</summary>
|
||||
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
||||
|
||||
/// <summary>Returns no scope; the fake does not track scopes.</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
/// <param name="state">The scope state (ignored).</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level to check.</param>
|
||||
/// <returns>Always true.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Records the log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="logLevel">The severity of the entry.</param>
|
||||
/// <param name="eventId">The event identifier.</param>
|
||||
/// <param name="state">The state object to format.</param>
|
||||
/// <param name="exception">The exception associated with the entry, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message.</param>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
@@ -77,6 +94,8 @@ public class AuditLogPartitionMaintenanceServiceTests
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the service starts and stops without throwing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StartStop_NoExceptions()
|
||||
{
|
||||
@@ -113,6 +132,8 @@ public class AuditLogPartitionMaintenanceServiceTests
|
||||
Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an exception thrown during maintenance is logged and not propagated.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SafeMaintain_ExceptionLogged_NotPropagated()
|
||||
{
|
||||
@@ -152,10 +173,12 @@ public class AuditLogPartitionMaintenanceServiceTests
|
||||
Assert.Equal(1, maintenance.EnsureCallCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stopping the service after it has been disposed does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
||||
{
|
||||
// Regression (Gitea #15 follow-up): Dispose tears down the CTS that
|
||||
// Regression: Dispose tears down the CTS that
|
||||
// StopAsync cancels, and the host does not guarantee that every
|
||||
// IHostedService.StopAsync is driven before the DI container is
|
||||
// disposed — WebApplicationFactory's teardown reaches Dispose first and
|
||||
|
||||
@@ -18,18 +18,20 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle C (#23 M6-T4) tests for <see cref="AuditLogPurgeActor"/>. The fast,
|
||||
/// Tests for <see cref="AuditLogPurgeActor"/>. The fast,
|
||||
/// schedule-only tests substitute a recording stub for
|
||||
/// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation
|
||||
/// + event-publish machinery can be exercised without an MSSQL container.
|
||||
/// The end-to-end "real partition gets switched out" assertion lives in the
|
||||
/// repository tests (Bundle C of M6-T4); this actor file is purely about the
|
||||
/// repository tests; this actor file is purely about the
|
||||
/// actor's policy decisions.
|
||||
/// </summary>
|
||||
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||
public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -45,38 +47,52 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
/// </summary>
|
||||
private sealed class RecordingRepo : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
/// <summary>Gets the thresholds passed to each partition-boundary query.</summary>
|
||||
public List<DateTime> ThresholdQueries { get; } = new();
|
||||
/// <summary>Gets the partition boundaries that were switched out, in call order.</summary>
|
||||
public List<DateTime> SwitchedBoundaries { get; } = new();
|
||||
/// <summary>Gets or sets the function that returns the row count reported for a switched boundary.</summary>
|
||||
public Func<DateTime, long> RowsPerBoundary { get; set; } = _ => 0L;
|
||||
/// <summary>Gets or sets the boundary whose switch-out should throw, if any.</summary>
|
||||
public DateTime? ThrowOnBoundary { get; set; }
|
||||
/// <summary>Gets or sets the exception thrown for <see cref="ThrowOnBoundary"/>; a default failure is used when null.</summary>
|
||||
public Exception? BoundaryException { get; set; }
|
||||
|
||||
// Task 4: captures the maintenance command timeout the actor threads through to the
|
||||
// Captures the maintenance command timeout the actor threads through to the
|
||||
// switch-out + per-channel purge, so tests can assert the options value reaches the repo.
|
||||
/// <summary>Gets the command timeouts passed to each partition switch-out call.</summary>
|
||||
public List<TimeSpan?> SwitchTimeouts { get; } = new();
|
||||
/// <summary>Gets the command timeouts passed to each per-channel purge call.</summary>
|
||||
public List<TimeSpan?> ChannelTimeouts { get; } = new();
|
||||
|
||||
// M5.5 (T3): records every per-channel purge call as
|
||||
// Records every per-channel purge call as
|
||||
// (channel, threshold, batchSize) so tests can assert which channels the
|
||||
// actor chose to purge and with what window.
|
||||
/// <summary>Gets every per-channel purge call as a channel, threshold, batch size and row ceiling.</summary>
|
||||
public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new();
|
||||
/// <summary>Gets or sets the function that returns the row count reported for a purged channel.</summary>
|
||||
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
|
||||
|
||||
// The actor enumerator returns whichever list is configured here.
|
||||
// Mutating this between ticks lets tests simulate "no longer
|
||||
// eligible" boundaries on the second tick.
|
||||
/// <summary>Gets or sets the partition boundaries returned by the next threshold query.</summary>
|
||||
public List<DateTime> Boundaries { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||
{
|
||||
SwitchTimeouts.Add(commandTimeout);
|
||||
@@ -88,6 +104,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
return Task.FromResult(RowsPerBoundary(monthBoundary));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default)
|
||||
{
|
||||
@@ -95,6 +112,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
|
||||
{
|
||||
@@ -103,18 +121,22 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
return Task.FromResult(RowsPerChannel(channel));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
@@ -194,6 +216,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// 1. Tick_Fires_OnDailyInterval
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the purge actor runs a purge pass on each daily interval tick.</summary>
|
||||
[Fact]
|
||||
public void Tick_Fires_OnDailyInterval()
|
||||
{
|
||||
@@ -216,6 +239,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// 2. Tick_OldPartitions_SwitchedOut
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a tick switches out partitions older than the retention threshold.</summary>
|
||||
[Fact]
|
||||
public void Tick_OldPartitions_SwitchedOut()
|
||||
{
|
||||
@@ -247,6 +271,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// 3. Tick_NewerPartitions_Untouched
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a tick leaves partitions newer than the retention threshold untouched.</summary>
|
||||
[Fact]
|
||||
public void Tick_NewerPartitions_Untouched()
|
||||
{
|
||||
@@ -276,6 +301,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// 4. Tick_PublishesPurgedEvent_WithRowCount
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a tick publishes a purged event carrying the number of rows removed.</summary>
|
||||
[Fact]
|
||||
public void Tick_PublishesPurgedEvent_WithRowCount()
|
||||
{
|
||||
@@ -301,6 +327,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// 5. Tick_SwitchThrows_OtherPartitionsStillProcessed (continue-on-error)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a failed switch-out does not prevent the remaining partitions from being processed.</summary>
|
||||
[Fact]
|
||||
public void Tick_SwitchThrows_OtherPartitionsStillProcessed()
|
||||
{
|
||||
@@ -333,6 +360,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a real partition is switched out end to end, its rows are removed and a purged event is published.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
|
||||
{
|
||||
@@ -401,7 +430,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
Options.Create(auditOptions),
|
||||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||||
|
||||
// Fish for the Jan-2026 partition boundary — the only eligible one in this
|
||||
// Fish for the January 2026 partition boundary — the only eligible one in this
|
||||
// fixture DB. The generous timeout covers the real drop-and-rebuild dance
|
||||
// against MSSQL which routinely takes a couple of seconds on a busy dev container.
|
||||
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
@@ -436,6 +465,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// 7. Threshold_UsesAuditLogOptionsRetentionDays
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the purge threshold is derived from the configured audit log retention days.</summary>
|
||||
[Fact]
|
||||
public void Threshold_UsesAuditLogOptionsRetentionDays()
|
||||
{
|
||||
@@ -467,9 +497,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge (M5.5 T3)
|
||||
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a per-channel retention override shorter than the global window triggers a channel purge.</summary>
|
||||
[Fact]
|
||||
public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge()
|
||||
{
|
||||
@@ -515,9 +546,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge (M5.5 T3)
|
||||
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a per-channel retention override equal to or longer than the global window does not trigger a channel purge.</summary>
|
||||
[Fact]
|
||||
public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge()
|
||||
{
|
||||
@@ -561,9 +593,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 10. PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository (Task 4)
|
||||
// 10. PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a purge tick passes the configured maintenance command timeout to the repository.</summary>
|
||||
[Fact]
|
||||
public void PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository()
|
||||
{
|
||||
@@ -608,9 +641,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter (Task 5)
|
||||
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a failing switch-out publishes a purge-failed event and increments the health failure counter.</summary>
|
||||
[Fact]
|
||||
public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter()
|
||||
{
|
||||
|
||||
@@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Task 4 tests for <see cref="AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes"/> /
|
||||
/// Tests for <see cref="AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes"/> /
|
||||
/// <see cref="AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout"/>: the maintenance timeout
|
||||
/// defaults to 30 minutes and is clamped to a 1-minute floor so a misconfigured 0/negative value
|
||||
/// cannot yield a zero/negative ADO.NET command timeout (arch-review 04, S2).
|
||||
/// </summary>
|
||||
public class AuditLogPurgeOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that the maintenance command timeout defaults to 30 minutes.</summary>
|
||||
[Fact]
|
||||
public void MaintenanceCommandTimeout_Defaults_To_30_Minutes()
|
||||
{
|
||||
@@ -18,6 +19,9 @@ public class AuditLogPurgeOptionsTests
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), options.ResolvedMaintenanceCommandTimeout);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the resolved maintenance command timeout is clamped to a minimum of one minute.</summary>
|
||||
/// <param name="configured">The configured timeout in minutes.</param>
|
||||
/// <param name="expectedMinutes">The expected resolved timeout in minutes.</param>
|
||||
[Theory]
|
||||
[InlineData(0, 1)] // clamp floor — a zero config value cannot become a zero timeout
|
||||
[InlineData(-5, 1)] // clamp floor — negatives are rejected by ADO.NET
|
||||
|
||||
+8
@@ -12,6 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
/// </summary>
|
||||
public class AuditLogPurgeOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that the default options are valid.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -19,6 +21,8 @@ public class AuditLogPurgeOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero interval in hours fails validation.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ZeroIntervalHours_Fails()
|
||||
{
|
||||
@@ -29,6 +33,8 @@ public class AuditLogPurgeOptionsValidatorTests
|
||||
f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero channel purge batch size fails validation.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ZeroChannelPurgeBatchSize_Fails()
|
||||
{
|
||||
@@ -40,6 +46,8 @@ public class AuditLogPurgeOptionsValidatorTests
|
||||
f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maintenance command timeout fails validation.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void ZeroMaintenanceCommandTimeout_Fails()
|
||||
{
|
||||
|
||||
+8
-4
@@ -11,16 +11,17 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor
|
||||
/// failure bridge. M5 wired the SITE bridge
|
||||
/// Coverage for the central-side payload-filter redactor
|
||||
/// failure bridge. The SITE bridge
|
||||
/// (<c>HealthMetricsAuditRedactionFailureCounter</c>) that pushes increments
|
||||
/// into the site health report; M6 mirrors that with
|
||||
/// into the site health report; the central bridge mirrors that with
|
||||
/// <see cref="CentralAuditRedactionFailureCounter"/> so the same payload
|
||||
/// filter — when it runs on the central writer paths — surfaces failures on
|
||||
/// the central <see cref="AuditCentralHealthSnapshot"/>.
|
||||
/// </summary>
|
||||
public class CentralAuditRedactionFailureCounterTests : TestKit
|
||||
{
|
||||
/// <summary>Verifies that incrementing the counter routes the failure to the central health snapshot.</summary>
|
||||
[Fact]
|
||||
public void Increment_Routes_To_Snapshot()
|
||||
{
|
||||
@@ -34,6 +35,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
|
||||
Assert.Equal(3, snapshot.AuditRedactionFailure);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that constructing the counter with a null snapshot throws.</summary>
|
||||
[Fact]
|
||||
public void Construction_With_Null_Snapshot_Throws()
|
||||
{
|
||||
@@ -41,6 +43,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
|
||||
() => new CentralAuditRedactionFailureCounter(null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registering central maintenance replaces the redaction failure counter with the central implementation.</summary>
|
||||
[Fact]
|
||||
public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl()
|
||||
{
|
||||
@@ -72,11 +75,12 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
|
||||
Assert.IsType<CentralAuditRedactionFailureCounter>(counter);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default redaction failure counter registered by the audit log is a no-op.</summary>
|
||||
[Fact]
|
||||
public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp()
|
||||
{
|
||||
// Sanity check: without AddAuditLogCentralMaintenance the default
|
||||
// remains the NoOp from M5 — the central bridge only takes effect
|
||||
// remains the NoOp — the central bridge only takes effect
|
||||
// when the central-only registration runs.
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
|
||||
+24
-2
@@ -13,7 +13,7 @@ using IAuditInboundCeilingHitsCounter = ZB.MOM.WW.ScadaBridge.AuditLog.Central.I
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E (M6-T8) regression coverage for the central-side audit-write
|
||||
/// Regression coverage for the central-side audit-write
|
||||
/// failure counter. <see cref="CentralAuditWriter"/> and
|
||||
/// <see cref="AuditLogIngestActor"/> both swallow repository throws (audit
|
||||
/// must NEVER abort the user-facing action, alog.md §13) but bump the
|
||||
@@ -37,33 +37,43 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
/// </summary>
|
||||
private sealed class ThrowingRepo : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("simulated repo failure");
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
Task.FromResult(0L);
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
@@ -75,10 +85,14 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter
|
||||
{
|
||||
private int _count;
|
||||
/// <summary>Gets the number of increments recorded so far.</summary>
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
/// <inheritdoc />
|
||||
public void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a forced repository failure increments the audit-write failure counter.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Forced_Failure_Increments_Counter()
|
||||
{
|
||||
@@ -101,6 +115,8 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
Assert.Equal(1, counter.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a repository failure inside the ingest actor increments the audit-write failure counter.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditLogIngestActor_Failure_Increments_Counter()
|
||||
{
|
||||
@@ -128,6 +144,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
Assert.Equal(batch.Length, counter.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the health snapshot aggregates the failure counters together with the stalled-telemetry state.</summary>
|
||||
[Fact]
|
||||
public void Snapshot_Aggregates_Counters_And_StalledState()
|
||||
{
|
||||
@@ -165,6 +182,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
Assert.Equal(1, snapshot.AuditRedactionFailure);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a newly constructed health snapshot reports empty counters.</summary>
|
||||
[Fact]
|
||||
public void Snapshot_Empty_OnConstruction()
|
||||
{
|
||||
@@ -178,11 +196,12 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) AuditInboundCeilingHits counter
|
||||
// AuditInboundCeilingHits counter
|
||||
// AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter.
|
||||
// Incrementing through the interface surface is reflected on the snapshot.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the inbound ceiling-hit counter starts at zero.</summary>
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_StartsAtZero()
|
||||
{
|
||||
@@ -190,6 +209,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
Assert.Equal(0, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that incrementing the ceiling-hit counter through its interface is reflected on the snapshot.</summary>
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot()
|
||||
{
|
||||
@@ -203,6 +223,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
Assert.Equal(3, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent increments of the ceiling-hit counter are not lost.</summary>
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IsThreadSafe()
|
||||
{
|
||||
@@ -217,6 +238,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the ceiling-hit counter does not affect the other health counters.</summary>
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IsIndependentOfOtherCounters()
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
|
||||
/// Unit tests for <see cref="CentralAuditWriter"/>, the
|
||||
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
||||
/// The writer is a thin wrapper around
|
||||
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
|
||||
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
/// </summary>
|
||||
public class CentralAuditWriterTests
|
||||
{
|
||||
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
|
||||
// Canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
|
||||
private static AuditEvent NewEvent(Guid? eventId = null) =>
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.Notification,
|
||||
@@ -43,6 +43,8 @@ public class CentralAuditWriterTests
|
||||
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAsync passes the event to the repository's InsertIfNotExistsAsync.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
|
||||
{
|
||||
@@ -56,6 +58,8 @@ public class CentralAuditWriterTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAsync stamps the ingested-at UTC time on the event before inserting it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
|
||||
{
|
||||
@@ -66,7 +70,7 @@ public class CentralAuditWriterTests
|
||||
|
||||
var after = DateTime.UtcNow;
|
||||
await repo.Received(1).InsertIfNotExistsAsync(
|
||||
// C3 (Task 2.5): IngestedAtUtc now rides in DetailsJson on the canonical
|
||||
// IngestedAtUtc now rides in DetailsJson on the canonical
|
||||
// record — read it back via the decomposed row view.
|
||||
Arg.Is<AuditEvent>(e =>
|
||||
e.AsRow().IngestedAtUtc != null &&
|
||||
@@ -75,6 +79,8 @@ public class CentralAuditWriterTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an exception thrown by the repository during WriteAsync does not propagate to the caller.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
|
||||
{
|
||||
@@ -90,6 +96,8 @@ public class CentralAuditWriterTests
|
||||
await writer.WriteAsync(NewEvent());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAsync resolves the repository from a fresh scope on every call.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
|
||||
{
|
||||
@@ -114,6 +122,7 @@ public class CentralAuditWriterTests
|
||||
Assert.NotSame(instances[0], instances[1]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that constructing the writer with a null service provider throws.</summary>
|
||||
[Fact]
|
||||
public void Constructor_NullServices_Throws()
|
||||
{
|
||||
@@ -121,6 +130,7 @@ public class CentralAuditWriterTests
|
||||
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that constructing the writer with a null logger throws.</summary>
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_Throws()
|
||||
{
|
||||
@@ -129,7 +139,7 @@ public class CentralAuditWriterTests
|
||||
() => new CentralAuditWriter(services, null!));
|
||||
}
|
||||
|
||||
// ----- SourceNode stamping (Task 12) ----- //
|
||||
// ----- SourceNode stamping ----- //
|
||||
|
||||
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
|
||||
INodeIdentityProvider? nodeIdentity)
|
||||
@@ -147,6 +157,8 @@ public class CentralAuditWriterTests
|
||||
return (writer, repo);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAsync stamps the source node from the identity provider when the event has none.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
|
||||
{
|
||||
@@ -159,6 +171,8 @@ public class CentralAuditWriterTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAsync preserves a source node supplied by the caller.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
|
||||
{
|
||||
@@ -172,6 +186,8 @@ public class CentralAuditWriterTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAsync leaves the source node null when the identity provider returns null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
|
||||
{
|
||||
@@ -184,11 +200,13 @@ public class CentralAuditWriterTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAsync passes the caller's source node through unchanged when no identity provider is injected.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
|
||||
{
|
||||
// Locks the back-compat contract for the optional `nodeIdentity = null`
|
||||
// ctor parameter: when no provider is wired (e.g. legacy M4 test
|
||||
// ctor parameter: when no provider is wired (e.g. legacy test
|
||||
// composition roots), the writer must not stamp — caller value passes
|
||||
// through unmodified. Distinct code path from
|
||||
// "provider supplied, returns null", which the test above covers.
|
||||
@@ -201,6 +219,8 @@ public class CentralAuditWriterTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAsync leaves the source node null when neither the caller nor an identity provider supplies one.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected()
|
||||
{
|
||||
|
||||
+39
-3
@@ -12,7 +12,7 @@ using ProtoPullResponse = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEven
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle (M6) tests for <see cref="GrpcPullAuditEventsClient"/> — the
|
||||
/// Tests for <see cref="GrpcPullAuditEventsClient"/> — the
|
||||
/// production <see cref="IPullAuditEventsClient"/> that dials a site over gRPC
|
||||
/// and issues the <c>PullAuditEvents</c> unary RPC for the reconciliation loop.
|
||||
/// The real <c>GrpcChannel</c> is replaced by an injected
|
||||
@@ -29,7 +29,10 @@ public class GrpcPullAuditEventsClientTests
|
||||
private sealed class StaticEnumerator : ISiteEnumerator
|
||||
{
|
||||
private readonly IReadOnlyList<SiteEntry> _sites;
|
||||
/// <summary>Initializes the enumerator with the fixed set of sites it will return.</summary>
|
||||
/// <param name="sites">The sites the enumerator returns on every call.</param>
|
||||
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(_sites);
|
||||
}
|
||||
@@ -41,8 +44,11 @@ public class GrpcPullAuditEventsClientTests
|
||||
/// </summary>
|
||||
private sealed class FakeInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
|
||||
{
|
||||
/// <summary>Gets the endpoint most recently dialed through this invoker.</summary>
|
||||
public string? Endpoint { get; private set; }
|
||||
/// <summary>Gets the request most recently passed to this invoker.</summary>
|
||||
public ProtoPullRequest? Request { get; private set; }
|
||||
/// <summary>Gets the number of times this invoker has been called.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
private readonly ProtoPullResponse? _response;
|
||||
@@ -54,9 +60,17 @@ public class GrpcPullAuditEventsClientTests
|
||||
_throw = toThrow;
|
||||
}
|
||||
|
||||
/// <summary>Creates an invoker that returns the given response.</summary>
|
||||
/// <param name="response">The response returned from every invocation.</param>
|
||||
/// <returns>An invoker that returns the given response.</returns>
|
||||
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
|
||||
/// <summary>Creates an invoker that throws the given exception.</summary>
|
||||
/// <param name="ex">The exception thrown from every invocation.</param>
|
||||
/// <returns>An invoker that throws the given exception.</returns>
|
||||
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
|
||||
|
||||
// Records the call and returns the canned response or throws the canned exception.
|
||||
/// <inheritdoc />
|
||||
public Task<ProtoPullResponse> InvokeAsync(
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
@@ -72,17 +86,22 @@ public class GrpcPullAuditEventsClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-endpoint fake invoker (Task 18): routes dials by endpoint string so a
|
||||
/// Per-endpoint fake invoker: routes dials by endpoint string so a
|
||||
/// NodeA-down / NodeB-up failover can be scripted. Records dialed endpoints.
|
||||
/// </summary>
|
||||
private sealed class PerEndpointInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
|
||||
{
|
||||
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
|
||||
/// <summary>Gets the endpoints dialed so far, in call order.</summary>
|
||||
public List<string> Dialed { get; } = new();
|
||||
|
||||
/// <summary>Initializes the invoker with the scripted response factory for each endpoint.</summary>
|
||||
/// <param name="byEndpoint">The response factory keyed by endpoint address.</param>
|
||||
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
|
||||
_byEndpoint = byEndpoint;
|
||||
|
||||
// Records the dialed endpoint and returns the response scripted for it.
|
||||
/// <inheritdoc />
|
||||
public Task<ProtoPullResponse> InvokeAsync(
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
@@ -100,6 +119,8 @@ public class GrpcPullAuditEventsClientTests
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: "site-a"));
|
||||
|
||||
/// <summary>Verifies that the pull dials the resolved endpoint and maps the returned events oldest first.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_dials_the_resolved_endpoint_and_maps_events_oldest_first()
|
||||
{
|
||||
@@ -132,6 +153,8 @@ public class GrpcPullAuditEventsClientTests
|
||||
Assert.Equal(newer, result.Events[1].EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pulling from a site with no known endpoint returns an empty response.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
|
||||
{
|
||||
@@ -148,6 +171,9 @@ public class GrpcPullAuditEventsClientTests
|
||||
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tolerable transport faults are swallowed and produce an empty response.</summary>
|
||||
/// <param name="code">The gRPC status code the invoker fails with.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(StatusCode.Unavailable)] // connection refused / site offline
|
||||
[InlineData(StatusCode.DeadlineExceeded)] // slow site / network blip
|
||||
@@ -167,6 +193,8 @@ public class GrpcPullAuditEventsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that connection-layer faults are swallowed and produce an empty response.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
|
||||
{
|
||||
@@ -184,6 +212,8 @@ public class GrpcPullAuditEventsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unexpected faults are swallowed and produce an empty response.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
|
||||
{
|
||||
@@ -203,6 +233,8 @@ public class GrpcPullAuditEventsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unspecified minimum-value cursor does not throw and the site is still dialed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
|
||||
{
|
||||
@@ -233,10 +265,12 @@ public class GrpcPullAuditEventsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the pull fails over to the secondary node when the primary raises a transport fault.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
|
||||
{
|
||||
// Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the
|
||||
// NodeA (primary) is unreachable, NodeB (fallback) answers — the
|
||||
// pull must return NodeB's events rather than collapsing to empty.
|
||||
var id = Guid.NewGuid();
|
||||
var fallbackResponse = new ProtoPullResponse { MoreAvailable = false };
|
||||
@@ -259,6 +293,8 @@ public class GrpcPullAuditEventsClientTests
|
||||
Assert.Equal(id, evt.EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no failover is attempted when the site has no fallback endpoint configured.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
/// <summary>
|
||||
/// Tests for <see cref="GrpcPullSiteCallsClient"/> — the production
|
||||
/// <see cref="IPullSiteCallsClient"/> that dials a site over gRPC and issues the
|
||||
/// <c>PullSiteCalls</c> unary RPC for the Site Call Audit (#22) reconciliation
|
||||
/// <c>PullSiteCalls</c> unary RPC for the Site Call Audit reconciliation
|
||||
/// loop. The real <c>GrpcChannel</c> is replaced by an injected
|
||||
/// <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/> seam so the
|
||||
/// client's mapping / ordering / SourceSite-restamp / fault-swallowing behaviour
|
||||
@@ -26,15 +26,21 @@ public class GrpcPullSiteCallsClientTests
|
||||
private sealed class StaticEnumerator : ISiteEnumerator
|
||||
{
|
||||
private readonly IReadOnlyList<SiteEntry> _sites;
|
||||
/// <summary>Initializes a new instance of the <c>StaticEnumerator</c> class.</summary>
|
||||
/// <param name="sites">The sites.</param>
|
||||
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(_sites);
|
||||
}
|
||||
|
||||
private sealed class FakeInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
|
||||
{
|
||||
/// <summary>Gets the endpoint most recently dialed.</summary>
|
||||
public string? Endpoint { get; private set; }
|
||||
/// <summary>Gets the request most recently received.</summary>
|
||||
public ProtoPullRequest? Request { get; private set; }
|
||||
/// <summary>Gets the number of calls made.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
private readonly ProtoPullResponse? _response;
|
||||
@@ -46,9 +52,16 @@ public class GrpcPullSiteCallsClientTests
|
||||
_throw = toThrow;
|
||||
}
|
||||
|
||||
/// <summary>Creates an invoker that returns the given response.</summary>
|
||||
/// <param name="response">The response to return.</param>
|
||||
/// <returns>A fake invoker returning <paramref name="response"/>.</returns>
|
||||
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
|
||||
/// <summary>Creates an invoker that throws the given exception.</summary>
|
||||
/// <param name="ex">The exception to throw.</param>
|
||||
/// <returns>A fake invoker that throws <paramref name="ex"/>.</returns>
|
||||
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProtoPullResponse> InvokeAsync(
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
@@ -64,18 +77,22 @@ public class GrpcPullSiteCallsClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-endpoint fake invoker (Task 18): dials are routed by endpoint string so
|
||||
/// Per-endpoint fake invoker: dials are routed by endpoint string so
|
||||
/// a NodeA-down / NodeB-up failover can be scripted. Records every dialed
|
||||
/// endpoint in order.
|
||||
/// </summary>
|
||||
private sealed class PerEndpointInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
|
||||
{
|
||||
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
|
||||
/// <summary>Gets the endpoints dialed, in order.</summary>
|
||||
public List<string> Dialed { get; } = new();
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>PerEndpointInvoker</c> class.</summary>
|
||||
/// <param name="byEndpoint">The by endpoint.</param>
|
||||
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
|
||||
_byEndpoint = byEndpoint;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProtoPullResponse> InvokeAsync(
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
@@ -103,6 +120,8 @@ public class GrpcPullSiteCallsClientTests
|
||||
UpdatedAtUtc = Timestamp.FromDateTime(updatedAtUtc),
|
||||
};
|
||||
|
||||
/// <summary>Verifies that PullAsync dials the resolved endpoint, maps rows oldest first and restamps the source site.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_dials_resolved_endpoint_maps_oldest_first_and_restamps_source_site()
|
||||
{
|
||||
@@ -144,6 +163,8 @@ public class GrpcPullSiteCallsClientTests
|
||||
Assert.Equal(1, result.SiteCalls[0].RetryCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PullAsync returns an empty response when the site endpoint is unknown.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
|
||||
{
|
||||
@@ -160,6 +181,9 @@ public class GrpcPullSiteCallsClientTests
|
||||
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PullAsync turns tolerable transport faults into an empty response.</summary>
|
||||
/// <param name="code">The code.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(StatusCode.Unavailable)]
|
||||
[InlineData(StatusCode.DeadlineExceeded)]
|
||||
@@ -178,6 +202,8 @@ public class GrpcPullSiteCallsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PullAsync turns connection-layer faults into an empty response.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
|
||||
{
|
||||
@@ -193,6 +219,8 @@ public class GrpcPullSiteCallsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PullAsync turns unexpected faults into an empty response.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
|
||||
{
|
||||
@@ -208,6 +236,8 @@ public class GrpcPullSiteCallsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PullAsync skips a poison row and still returns the good rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_skips_poison_row_and_returns_the_good_rows()
|
||||
{
|
||||
@@ -244,6 +274,8 @@ public class GrpcPullSiteCallsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PullAsync with a MinValue unspecified-kind cursor does not throw and still dials the site.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
|
||||
{
|
||||
@@ -271,10 +303,12 @@ public class GrpcPullSiteCallsClientTests
|
||||
Assert.False(result.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PullAsync fails over to node B when the primary raises a transport fault.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
|
||||
{
|
||||
// Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the
|
||||
// NodeA (primary) is unreachable, NodeB (fallback) answers — the
|
||||
// pull must return NodeB's rows rather than collapsing to empty.
|
||||
var id = Guid.NewGuid();
|
||||
var fallbackResponse = new ProtoPullResponse();
|
||||
@@ -299,6 +333,8 @@ public class GrpcPullSiteCallsClientTests
|
||||
Assert.Equal("site-a", row.SourceSite);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PullAsync does not fail over when no fallback is configured.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
|
||||
{
|
||||
|
||||
+82
-8
@@ -18,19 +18,21 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle B (M6-T3) tests for <see cref="SiteAuditReconciliationActor"/>. Most
|
||||
/// Tests for <see cref="SiteAuditReconciliationActor"/>. Most
|
||||
/// tests substitute the <see cref="IAuditLogRepository"/> with an in-memory
|
||||
/// recording stub so the actor's tick / cursor / stalled state machinery can
|
||||
/// be exercised in milliseconds without an MSSQL container. The duplicate /
|
||||
/// idempotency assertion uses the real <see cref="AuditLogRepository"/> against
|
||||
/// the <see cref="MsSqlMigrationFixture"/> so we verify InsertIfNotExistsAsync
|
||||
/// actually swallows duplicate-key collisions (the M2 Bundle A race-fix the
|
||||
/// actually swallows duplicate-key collisions (the race fix the
|
||||
/// reconciliation puller depends on).
|
||||
/// </summary>
|
||||
public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||
public SiteAuditReconciliationActorTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -68,13 +70,17 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
/// </summary>
|
||||
private sealed class RecordingRepo : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
/// <summary>Gets the audit events recorded by this stub.</summary>
|
||||
public List<AuditEvent> Inserted { get; } = new();
|
||||
private readonly HashSet<Guid> _seen = new();
|
||||
/// <summary>Gets the number of times <c>InsertIfNotExistsAsync</c> was called.</summary>
|
||||
public int InsertCallCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
InsertCallCount++;
|
||||
@@ -85,33 +91,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
@@ -120,24 +134,30 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
/// Recording stub whose <see cref="InsertIfNotExistsAsync"/> throws for ONE
|
||||
/// poison <see cref="AuditEvent.EventId"/> (every attempt) but records every
|
||||
/// other row — including the synthetic ReconciliationAbandoned row the actor
|
||||
/// emits once it gives up on the poison row (Task 17).
|
||||
/// emits once it gives up on the poison row.
|
||||
/// </summary>
|
||||
private sealed class PoisonInsertRepo : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
private readonly Guid _poisonId;
|
||||
private readonly Exception _fault;
|
||||
/// <summary>Gets the audit events recorded by this stub.</summary>
|
||||
public List<AuditEvent> Inserted { get; } = new();
|
||||
private readonly HashSet<Guid> _seen = new();
|
||||
|
||||
/// <summary>Initializes the repository so that inserting the given event id always throws the given fault.</summary>
|
||||
/// <param name="poisonId">The event id whose insert always fails.</param>
|
||||
/// <param name="fault">The exception thrown on every attempt to insert the poison row.</param>
|
||||
public PoisonInsertRepo(Guid poisonId, Exception fault)
|
||||
{
|
||||
_poisonId = poisonId;
|
||||
_fault = fault;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (evt.EventId == _poisonId)
|
||||
@@ -151,33 +171,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
@@ -191,19 +219,22 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
/// row that also never inserts but changes EventId every pull — i.e. it never
|
||||
/// individually reaches <c>MaxPermanentInsertAttempts</c>, so it keeps
|
||||
/// <c>hasUnresolvedFailure</c> true and holds the cursor at <c>since</c> forever
|
||||
/// (P5-6's defect trigger). Dedupes inserted rows on <see cref="AuditEvent.EventId"/>
|
||||
/// (the defect trigger). Dedupes inserted rows on <see cref="AuditEvent.EventId"/>
|
||||
/// exactly like the real <c>InsertIfNotExistsAsync</c>, so a test asserting on
|
||||
/// <see cref="Inserted"/>'s count is exercising the same idempotency contract
|
||||
/// production relies on.
|
||||
/// </summary>
|
||||
private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
/// <summary>Gets the audit events recorded by this stub.</summary>
|
||||
public List<AuditEvent> Inserted { get; } = new();
|
||||
private readonly HashSet<Guid> _seen = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (!evt.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal))
|
||||
@@ -217,33 +248,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
@@ -254,7 +293,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
private sealed class StaticEnumerator : ISiteEnumerator
|
||||
{
|
||||
private readonly IReadOnlyList<SiteEntry> _sites;
|
||||
/// <summary>Initializes the enumerator with a fixed set of sites.</summary>
|
||||
/// <param name="sites">The sites returned on every enumeration.</param>
|
||||
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(_sites);
|
||||
}
|
||||
@@ -266,22 +308,32 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
/// </summary>
|
||||
private sealed class ScriptedPullClient : IPullAuditEventsClient
|
||||
{
|
||||
/// <summary>Gets the recorded pull calls in the order they were made.</summary>
|
||||
public List<(string SiteId, DateTime SinceUtc, string? AfterId, int BatchSize)> Calls { get; } = new();
|
||||
private readonly Dictionary<string, Queue<PullAuditEventsResponse>> _scripted = new();
|
||||
private readonly Dictionary<string, Exception> _throwOnSite = new();
|
||||
|
||||
/// <summary>Queues scripted responses for the given site.</summary>
|
||||
/// <param name="siteId">The site whose pulls receive the scripted responses.</param>
|
||||
/// <param name="responses">The responses returned in order on successive pulls.</param>
|
||||
/// <returns>This client, for chaining.</returns>
|
||||
public ScriptedPullClient Script(string siteId, params PullAuditEventsResponse[] responses)
|
||||
{
|
||||
_scripted[siteId] = new Queue<PullAuditEventsResponse>(responses);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Makes every pull for the given site throw the given exception.</summary>
|
||||
/// <param name="siteId">The site whose pulls fail.</param>
|
||||
/// <param name="ex">The exception to throw.</param>
|
||||
/// <returns>This client, for chaining.</returns>
|
||||
public ScriptedPullClient ThrowFor(string siteId, Exception ex)
|
||||
{
|
||||
_throwOnSite[siteId] = ex;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PullAuditEventsResponse> PullAsync(
|
||||
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
||||
{
|
||||
@@ -373,6 +425,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// 1. Timer_Fires_OnConfiguredInterval
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the reconciliation timer fires on the configured interval.</summary>
|
||||
[Fact]
|
||||
public void Timer_Fires_OnConfiguredInterval()
|
||||
{
|
||||
@@ -400,6 +453,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// 2. Tick_PullsFromEachKnownSite
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that each tick pulls from every known site.</summary>
|
||||
[Fact]
|
||||
public void Tick_PullsFromEachKnownSite()
|
||||
{
|
||||
@@ -426,6 +480,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that pulled events are ingested through the repository insert-if-not-exists call.</summary>
|
||||
[Fact]
|
||||
public void Tick_IngestEvents_ViaInsertIfNotExistsAsync()
|
||||
{
|
||||
@@ -456,6 +511,8 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString).Options);
|
||||
|
||||
/// <summary>Verifies that duplicate events are not inserted twice.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Tick_Duplicates_NotDoubleInserted()
|
||||
{
|
||||
@@ -509,6 +566,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// 5. Cursor_Advances_ToMaxOccurredAtUtc
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the cursor advances to the maximum occurred-at timestamp of the pulled events.</summary>
|
||||
[Fact]
|
||||
public void Cursor_Advances_ToMaxOccurredAtUtc()
|
||||
{
|
||||
@@ -545,13 +603,14 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
|
||||
Assert.Equal(t3, client.Calls[1].SinceUtc);
|
||||
|
||||
// The composite half travels too (arch-review phase-2 residual #5): the first pull has
|
||||
// The composite half travels too: the first pull has
|
||||
// no cursor at all, the second carries the id of the row at t3 so the site can retire
|
||||
// it exactly instead of leaving the boundary instant permanently servable.
|
||||
Assert.Null(client.Calls[0].AfterId);
|
||||
Assert.Equal(e3.EventId.ToString(), client.Calls[1].AfterId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the cursor advances on the event id tiebreak when every row shares one instant.</summary>
|
||||
[Fact]
|
||||
public void Cursor_AdvancesOnTheIdTiebreak_WhenEveryRowSharesOneInstant()
|
||||
{
|
||||
@@ -583,6 +642,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
Assert.Equal(high.EventId.ToString(), client.Calls[1].AfterId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that both cursor halves are held back while a row is still being retried.</summary>
|
||||
[Fact]
|
||||
public void BothCursorHalves_AreHeldBack_WhileARowIsStillBeingRetried()
|
||||
{
|
||||
@@ -612,41 +672,51 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
/// <summary>Repository whose every insert throws, so the retry hold-back path is taken.</summary>
|
||||
private sealed class AlwaysThrowingRepo : IAuditLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("central insert failed");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> SwitchOutPartitionAsync(
|
||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null,
|
||||
CancellationToken ct = default, int maxRows = 50_000)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -655,6 +725,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// 6. Tick_OneSiteThrows_OtherSitesStillProcessed
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a failure pulling from one site does not stop the other sites from being processed.</summary>
|
||||
[Fact]
|
||||
public void Tick_OneSiteThrows_OtherSitesStillProcessed()
|
||||
{
|
||||
@@ -686,6 +757,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// 7. StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that two consecutive non-draining cycles publish a stalled state of true.</summary>
|
||||
[Fact]
|
||||
public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue()
|
||||
{
|
||||
@@ -717,6 +789,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// 8. StalledDetection_DrainingCycle_PublishesStalledFalse
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a draining cycle publishes a stalled state of false.</summary>
|
||||
[Fact]
|
||||
public void StalledDetection_DrainingCycle_PublishesStalledFalse()
|
||||
{
|
||||
@@ -750,11 +823,12 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 9. Task 17: a permanently-abandoned row leaves a durable
|
||||
// 9. A permanently-abandoned row leaves a durable
|
||||
// ReconciliationAbandoned audit record (not just a Critical log line),
|
||||
// and the cursor advances past it.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a permanently abandoned row emits a ReconciliationAbandoned row and advances the cursor.</summary>
|
||||
[Fact]
|
||||
public void PermanentAbandonment_EmitsReconciliationAbandonedRow_AndAdvancesCursor()
|
||||
{
|
||||
@@ -804,11 +878,11 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 10. P5-6: deterministic synthetic ReconciliationAbandoned EventId
|
||||
// 10. Deterministic synthetic ReconciliationAbandoned EventId
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Reproduces the P5-6 defect end to end: a permanently-poisoned row sits
|
||||
/// Reproduces the rotating-poison defect end to end: a permanently-poisoned row sits
|
||||
/// in a batch alongside a co-batch row that ALSO fails on every pull but
|
||||
/// carries a fresh <see cref="AuditEvent.EventId"/> each time (modelling a
|
||||
/// site that keeps producing new events with transient insert failures).
|
||||
|
||||
+7
@@ -12,6 +12,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
/// </summary>
|
||||
public class SiteAuditReconciliationOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that the batch size is valid only up to the RPC ceiling.</summary>
|
||||
/// <param name="size">The batch size under test.</param>
|
||||
/// <param name="valid">Whether the batch size is expected to validate.</param>
|
||||
[Theory]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(2000, true)]
|
||||
@@ -21,6 +24,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
|
||||
=> Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null,
|
||||
new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded);
|
||||
|
||||
/// <summary>Verifies that the default options pass validation.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -28,6 +32,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero reconciliation interval fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroReconciliationInterval_Fails()
|
||||
{
|
||||
@@ -39,6 +44,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero batch size fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroBatchSize_Fails()
|
||||
{
|
||||
@@ -50,6 +56,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero stalled-cycle threshold fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroStalledCycles_Fails()
|
||||
{
|
||||
|
||||
+7
-1
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E (M6-T7) tests for <see cref="SiteAuditTelemetryStalledTracker"/>.
|
||||
/// Tests for <see cref="SiteAuditTelemetryStalledTracker"/>.
|
||||
/// The tracker subscribes to the actor system's EventStream for
|
||||
/// <see cref="SiteAuditTelemetryStalledChanged"/> publications and maintains a
|
||||
/// per-site latch the central health surface can read. Since reconciliation is
|
||||
@@ -36,6 +36,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
||||
interval: TimeSpan.FromMilliseconds(20));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a newly created tracker reports an empty snapshot.</summary>
|
||||
[Fact]
|
||||
public void Initial_Snapshot_IsEmpty()
|
||||
{
|
||||
@@ -46,6 +47,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
||||
Assert.Empty(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stalled event makes the tracker report the site as stalled.</summary>
|
||||
[Fact]
|
||||
public void StalledTrue_Event_TrackerReports_Stalled()
|
||||
{
|
||||
@@ -57,6 +59,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
||||
Assert.True(snapshot["siteA"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a not-stalled event makes the tracker report the site as not stalled.</summary>
|
||||
[Fact]
|
||||
public void StalledFalse_Event_TrackerReports_NotStalled()
|
||||
{
|
||||
@@ -72,6 +75,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
||||
Assert.False(snapshot["siteA"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple sites are tracked independently of one another.</summary>
|
||||
[Fact]
|
||||
public void Multiple_Sites_Tracked_Independently()
|
||||
{
|
||||
@@ -88,6 +92,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
||||
Assert.True(snapshot["siteC"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that constructing the tracker with a null actor system throws.</summary>
|
||||
[Fact]
|
||||
public void Constructor_With_Null_ActorSystem_Throws()
|
||||
{
|
||||
@@ -95,6 +100,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
||||
() => new SiteAuditTelemetryStalledTracker((ActorSystem)null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the tracker unsubscribes it from the event stream.</summary>
|
||||
[Fact]
|
||||
public void Dispose_Unsubscribes_From_EventStream()
|
||||
{
|
||||
|
||||
@@ -41,6 +41,8 @@ public class SiteEnumeratorTests
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sites with a NodeA address are projected and sites with a blank address are skipped.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnumerateAsync_ProjectsSitesWithNodeAAddress_AndSkipsBlankOnes()
|
||||
{
|
||||
@@ -60,6 +62,8 @@ public class SiteEnumeratorTests
|
||||
Assert.Equal("http://site-a:8083", entry.GrpcEndpoint);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site with a null NodeA address is skipped.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnumerateAsync_SkipsNullNodeAAddress()
|
||||
{
|
||||
@@ -76,10 +80,12 @@ public class SiteEnumeratorTests
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site with distinct NodeA and NodeB addresses projects NodeB as the fallback endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnumerateAsync_BothAddresses_ProjectsNodeBAsFallback()
|
||||
{
|
||||
// Task 18: a site with both NodeA and a DISTINCT NodeB yields
|
||||
// A site with both NodeA and a DISTINCT NodeB yields
|
||||
// SiteEntry(primary=NodeA, FallbackGrpcEndpoint=NodeB).
|
||||
var repository = Substitute.For<ISiteRepository>();
|
||||
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
|
||||
@@ -95,10 +101,12 @@ public class SiteEnumeratorTests
|
||||
Assert.Equal("http://site-b:8083", entry.FallbackGrpcEndpoint);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site with only a NodeB address is not skipped and has no fallback endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnumerateAsync_OnlyNodeB_IsNotSkipped_AndHasNoFallback()
|
||||
{
|
||||
// Task 18: a site with a blank NodeA but a valid NodeB is no longer
|
||||
// A site with a blank NodeA but a valid NodeB is no longer
|
||||
// skipped — NodeB becomes the primary and there is no separate fallback.
|
||||
var repository = Substitute.For<ISiteRepository>();
|
||||
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
|
||||
@@ -114,6 +122,8 @@ public class SiteEnumeratorTests
|
||||
Assert.Null(entry.FallbackGrpcEndpoint);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that enumeration returns an empty list when there are no sites.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnumerateAsync_ReturnsEmpty_WhenNoSites()
|
||||
{
|
||||
|
||||
+25
-6
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||
|
||||
/// <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
|
||||
/// (scalars, lists, per-target overrides) so accidental drift in the section
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <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
|
||||
/// 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>
|
||||
public class AuditLogOptionsBindingTests
|
||||
{
|
||||
/// <summary>Verifies that the AuditLog configuration section binds every field.</summary>
|
||||
[Fact]
|
||||
public void AuditLog_Section_Binds_AllFields()
|
||||
{
|
||||
@@ -71,7 +72,7 @@ public class AuditLogOptionsBindingTests
|
||||
|
||||
// HeaderRedactList: the Microsoft.Extensions.Configuration list binder
|
||||
// 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("Custom-Token", opts.HeaderRedactList);
|
||||
|
||||
@@ -90,10 +91,11 @@ public class AuditLogOptionsBindingTests
|
||||
Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the purge options bind from the documented section and keys.</summary>
|
||||
[Fact]
|
||||
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
|
||||
// keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the
|
||||
// code binds from EXACTLY that shape — the section path the production
|
||||
@@ -139,6 +141,7 @@ public class AuditLogOptionsBindingTests
|
||||
Assert.Equal(1000, opts.ChannelPurgeBatchSize);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the filter behavior updates when the configuration reloads.</summary>
|
||||
[Fact]
|
||||
public void Filter_Behavior_Updates_OnConfigReload()
|
||||
{
|
||||
@@ -165,7 +168,7 @@ public class AuditLogOptionsBindingTests
|
||||
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);
|
||||
|
||||
// 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.
|
||||
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
|
||||
|
||||
@@ -174,6 +177,7 @@ public class AuditLogOptionsBindingTests
|
||||
Assert.Equal(body, resultAfter.AsRow().RequestSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the filter picks up a new body redactor when the configuration reloads.</summary>
|
||||
[Fact]
|
||||
public void Filter_PicksUp_NewBodyRedactor_OnConfigReload()
|
||||
{
|
||||
@@ -219,12 +223,21 @@ public class AuditLogOptionsBindingTests
|
||||
private T _current;
|
||||
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;
|
||||
|
||||
/// <summary>Gets the current value value.</summary>
|
||||
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;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
lock (_listeners)
|
||||
@@ -234,6 +247,8 @@ public class AuditLogOptionsBindingTests
|
||||
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)
|
||||
{
|
||||
_current = value;
|
||||
@@ -252,11 +267,15 @@ public class AuditLogOptionsBindingTests
|
||||
{
|
||||
private readonly List<Action<T, string?>> _listeners;
|
||||
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)
|
||||
{
|
||||
_listeners = listeners;
|
||||
_listener = listener;
|
||||
}
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_listeners)
|
||||
|
||||
@@ -6,8 +6,8 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Task 9 (Bundle E): <see cref="AuditLogOptions"/> binding + validator
|
||||
/// behavior. The validator enforces invariants used by M2+ writers
|
||||
/// Tests for <see cref="AuditLogOptions"/> binding and validator
|
||||
/// behavior. The validator enforces invariants used by the audit writers
|
||||
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
|
||||
/// <c>DefaultCapBytes > 0</c>, <c>ErrorCapBytes >= DefaultCapBytes</c>,
|
||||
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
|
||||
@@ -25,6 +25,7 @@ public class AuditLogOptionsTests
|
||||
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a fully populated configuration section binds every scalar field onto the options.</summary>
|
||||
[Fact]
|
||||
public void ValidBinding_PopulatesAllScalarFields()
|
||||
{
|
||||
@@ -40,6 +41,7 @@ public class AuditLogOptionsTests
|
||||
Assert.Equal(180, opts.RetentionDays);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty configuration section yields reasonable default option values.</summary>
|
||||
[Fact]
|
||||
public void DefaultsAreReasonable_WhenSectionEmpty()
|
||||
{
|
||||
@@ -56,6 +58,7 @@ public class AuditLogOptionsTests
|
||||
Assert.Empty(opts.PerTargetOverrides);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that header redaction entries bound from configuration are appended to the default list.</summary>
|
||||
[Fact]
|
||||
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
|
||||
{
|
||||
@@ -77,6 +80,7 @@ public class AuditLogOptionsTests
|
||||
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that per-target override settings bind from configuration.</summary>
|
||||
[Fact]
|
||||
public void PerTargetOverrides_BindsFromConfig()
|
||||
{
|
||||
@@ -93,6 +97,7 @@ public class AuditLogOptionsTests
|
||||
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid default payload cap fails validation.</summary>
|
||||
[Fact]
|
||||
public void InvalidDefaultCapBytes_FailsValidation()
|
||||
{
|
||||
@@ -104,6 +109,7 @@ public class AuditLogOptionsTests
|
||||
Assert.Contains("DefaultCapBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid error payload cap fails validation.</summary>
|
||||
[Fact]
|
||||
public void InvalidErrorCapBytes_FailsValidation()
|
||||
{
|
||||
@@ -115,6 +121,7 @@ public class AuditLogOptionsTests
|
||||
Assert.Contains("ErrorCapBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retention period below the minimum fails validation.</summary>
|
||||
[Fact]
|
||||
public void RetentionDaysBelowMinimum_FailsValidation()
|
||||
{
|
||||
@@ -126,6 +133,7 @@ public class AuditLogOptionsTests
|
||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retention period above the maximum fails validation.</summary>
|
||||
[Fact]
|
||||
public void RetentionDaysAboveMaximum_FailsValidation()
|
||||
{
|
||||
@@ -137,6 +145,7 @@ public class AuditLogOptionsTests
|
||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default options pass validation.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_PassValidation()
|
||||
{
|
||||
@@ -147,6 +156,7 @@ public class AuditLogOptionsTests
|
||||
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]
|
||||
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
|
||||
{
|
||||
|
||||
+13
-2
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||
/// </summary>
|
||||
public class AuditLogOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that the default options set the inbound maximum body size to one mebibyte.</summary>
|
||||
[Fact]
|
||||
public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
|
||||
{
|
||||
@@ -24,6 +25,8 @@ public class AuditLogOptionsValidatorTests
|
||||
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]
|
||||
[InlineData(8_192)] // documented min
|
||||
[InlineData(1_048_576)] // default
|
||||
@@ -35,6 +38,8 @@ public class AuditLogOptionsValidatorTests
|
||||
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]
|
||||
[InlineData(0)]
|
||||
[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]
|
||||
public void Validate_PerChannelRetention_ShorterThanGlobal_Passes()
|
||||
{
|
||||
@@ -75,6 +81,7 @@ public class AuditLogOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a per-channel retention equal to the global retention passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_EqualToGlobal_Passes()
|
||||
{
|
||||
@@ -90,6 +97,7 @@ public class AuditLogOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a per-channel retention longer than the global retention fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_LongerThanGlobal_Fails()
|
||||
{
|
||||
@@ -110,6 +118,7 @@ public class AuditLogOptionsValidatorTests
|
||||
&& f.Contains("ApiInbound", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a per-channel retention below the minimum fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_BelowMinimum_Fails()
|
||||
{
|
||||
@@ -127,6 +136,7 @@ public class AuditLogOptionsValidatorTests
|
||||
f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a per-channel retention keyed by an unknown channel fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_UnknownChannelKey_Fails()
|
||||
{
|
||||
@@ -146,6 +156,7 @@ public class AuditLogOptionsValidatorTests
|
||||
f => f.Contains("NotAChannel", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default empty per-channel retention map passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_DefaultEmpty_Passes()
|
||||
{
|
||||
|
||||
+35
-9
@@ -36,17 +36,16 @@ using System.Text.Encodings.Web;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <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
|
||||
/// user-facing action. Exercises every boundary that emits audit rows in M2,
|
||||
/// M3, and M4:
|
||||
/// user-facing action. Exercises every boundary that emits audit rows:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>External system sync call (M2 Bundle F).</description></item>
|
||||
/// <item><description>External system cached call (M3 Bundle E).</description></item>
|
||||
/// <item><description>Database sync write (M4 Bundle A).</description></item>
|
||||
/// <item><description>Inbound API request (M4 Bundle D).</description></item>
|
||||
/// <item><description>Notification dispatcher (M4 Bundle B).</description></item>
|
||||
/// <item><description>External system sync call (sync).</description></item>
|
||||
/// <item><description>External system cached call (cached).</description></item>
|
||||
/// <item><description>Database sync write (sync write).</description></item>
|
||||
/// <item><description>Inbound API request (request).</description></item>
|
||||
/// <item><description>Notification dispatcher (dispatch).</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
@@ -64,6 +63,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -81,8 +82,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
private sealed class ThrowingAuditWriter : IAuditWriter
|
||||
{
|
||||
private int _attempts;
|
||||
/// <summary>Gets the number of times <c>WriteAsync</c> has been attempted.</summary>
|
||||
public int Attempts => Volatile.Read(ref _attempts);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _attempts);
|
||||
@@ -100,8 +103,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
private int _attempts;
|
||||
/// <summary>Gets the number of times <c>WriteAsync</c> has been attempted.</summary>
|
||||
public int Attempts => Volatile.Read(ref _attempts);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _attempts);
|
||||
@@ -118,8 +123,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
private sealed class ThrowingCachedForwarder : ICachedCallTelemetryForwarder
|
||||
{
|
||||
private int _attempts;
|
||||
/// <summary>Gets the number of times <c>ForwardAsync</c> has been attempted.</summary>
|
||||
public int Attempts => Volatile.Read(ref _attempts);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _attempts);
|
||||
@@ -132,6 +139,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
// 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]
|
||||
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.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <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]
|
||||
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.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <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]
|
||||
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.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <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]
|
||||
public async Task InboundApi_BrokenAuditWriter_StillReturns200()
|
||||
{
|
||||
@@ -290,6 +305,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
// 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]
|
||||
public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered()
|
||||
{
|
||||
@@ -381,7 +398,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
private async Task SeedSmtpConfigAsync()
|
||||
{
|
||||
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.
|
||||
ctx.SmtpConfigurations.Add(new SmtpConfiguration(
|
||||
"smtp.example.com", "Basic", "noreply@example.com")
|
||||
@@ -418,8 +435,12 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
private sealed class SingleOutcomeAdapter : INotificationDeliveryAdapter
|
||||
{
|
||||
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; }
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
/// <inheritdoc />
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_outcome);
|
||||
@@ -472,12 +493,17 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
: Microsoft.AspNetCore.Authentication.AuthenticationHandler<
|
||||
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(
|
||||
IOptionsMonitor<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> options,
|
||||
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<Microsoft.AspNetCore.Authentication.AuthenticateResult>
|
||||
HandleAuthenticateAsync()
|
||||
{
|
||||
|
||||
+10
-2
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="CachedCallTelemetryForwarder"/> + the test-side
|
||||
/// <see cref="CombinedTelemetryDispatcher"/> that ALSO pushes each combined
|
||||
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
/// <para>
|
||||
/// The bridge is driven directly via <see cref="CombinedTelemetryHarness.EmitAttemptAsync"/>
|
||||
/// — these tests do NOT spin up the actual S&F retry loop; that would
|
||||
/// require a full SiteRuntime host and is out of scope for M3 (the S&F
|
||||
/// require a full SiteRuntime host and is out of scope here (the S&F
|
||||
/// observer hooks are exercised in <c>ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests</c> at
|
||||
/// unit level). The submit row is emitted via
|
||||
/// <see cref="CombinedTelemetryHarness.EmitSubmitAsync"/> because the
|
||||
@@ -45,6 +45,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -106,6 +108,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
||||
DurationMs: 42,
|
||||
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]
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked()
|
||||
{
|
||||
@@ -227,6 +233,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
||||
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]
|
||||
public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered()
|
||||
{
|
||||
|
||||
+6
@@ -34,6 +34,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -93,6 +95,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
||||
DurationMs: 12,
|
||||
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]
|
||||
public async Task CachedWrite_Success_Emits_Delivered_Lifecycle()
|
||||
{
|
||||
@@ -144,6 +148,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
||||
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]
|
||||
public async Task CachedWrite_Parked_Emits_Terminal_Parked()
|
||||
{
|
||||
|
||||
+6
@@ -34,6 +34,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -100,6 +102,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
||||
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]
|
||||
public async Task DuplicatePacket_AuditLogStaysAtOneRow_SiteCallsUpserted_Monotonically()
|
||||
{
|
||||
@@ -144,6 +148,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
||||
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]
|
||||
public async Task OutOfOrderPackets_OlderStatus_ArrivesAfterNewer_IsNoOp()
|
||||
{
|
||||
|
||||
+8
-2
@@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <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> /
|
||||
/// <c>ExecuteReader</c> call made via the Bundle A
|
||||
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/> emits exactly one
|
||||
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <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"/> +
|
||||
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
|
||||
/// <see cref="SiteAuditTelemetryActor"/> through a
|
||||
@@ -57,6 +57,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -170,6 +172,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
||||
sourceScript: SourceScript,
|
||||
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]
|
||||
public async Task DbWrite_Insert_Emits_OneCentralRow_WithExtraOpWrite_AndRowsAffected()
|
||||
{
|
||||
@@ -240,6 +244,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
||||
}, 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]
|
||||
public async Task DbWrite_Select_Emits_OneCentralRow_WithExtraOpRead_AndRowsReturned()
|
||||
{
|
||||
|
||||
+7
-3
@@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <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
|
||||
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
|
||||
/// </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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>
|
||||
/// and the M4 <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
|
||||
/// Composes the same pipeline as the sync-call <see cref="SyncCallEmissionEndToEndTests"/>
|
||||
/// and the database-write <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
|
||||
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
||||
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
|
||||
/// <see cref="SiteAuditTelemetryActor"/> through the shared
|
||||
@@ -57,6 +57,8 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -146,6 +148,8 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
|
||||
FastTelemetryOptions(),
|
||||
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]
|
||||
public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId()
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ using System.Text.Encodings.Web;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <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
|
||||
/// <see cref="AuditWriteMiddleware"/> into a Microsoft.AspNetCore.TestHost
|
||||
/// pipeline that mirrors the production
|
||||
@@ -53,6 +53,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -144,12 +146,17 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
/// </summary>
|
||||
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(
|
||||
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
@@ -200,6 +207,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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]
|
||||
public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated()
|
||||
{
|
||||
@@ -232,6 +241,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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]
|
||||
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(401, evt.AsRow().HttpStatus);
|
||||
// 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
|
||||
// maps empty → null, preserving the "no principal" assertion.
|
||||
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]
|
||||
public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500()
|
||||
{
|
||||
|
||||
+10
-6
@@ -20,12 +20,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Production wiring keeps the wire push deferred to M6 — the site SQLite hot
|
||||
/// path is the source of truth and a future M6 drain will push the rows
|
||||
/// Production wiring keeps the wire push deferred — the site SQLite hot
|
||||
/// 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
|
||||
/// exercise the central dual-write transaction immediately, so this
|
||||
/// 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>
|
||||
/// <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 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(
|
||||
ICachedCallTelemetryForwarder inner,
|
||||
ISiteStreamAuditClient wireClient)
|
||||
@@ -48,7 +51,8 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
|
||||
_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)
|
||||
{
|
||||
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
|
||||
// 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.
|
||||
try
|
||||
{
|
||||
@@ -79,7 +83,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
|
||||
{
|
||||
// Swallow — the audit row is still in SQLite for a future drain;
|
||||
// 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).
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-1
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
|
||||
/// <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:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Site-local SQLite <see cref="SqliteAuditWriter"/> (in-memory) +
|
||||
@@ -48,15 +48,25 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
/// </remarks>
|
||||
public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The site SQLite audit writer under test.</summary>
|
||||
public SqliteAuditWriter SqliteWriter { get; }
|
||||
/// <summary>The in-memory ring buffer fallback for audit events.</summary>
|
||||
public RingBufferFallback Ring { get; }
|
||||
/// <summary>The fallback audit writer that wraps the SQLite writer and the ring buffer.</summary>
|
||||
public FallbackAuditWriter FallbackWriter { get; }
|
||||
/// <summary>The site-local operation tracking store.</summary>
|
||||
public OperationTrackingStore TrackingStore { get; }
|
||||
/// <summary>The inner cached-call telemetry forwarder.</summary>
|
||||
public CachedCallTelemetryForwarder InnerForwarder { get; }
|
||||
/// <summary>The combined telemetry dispatcher that sends audit and operational state together.</summary>
|
||||
public CombinedTelemetryDispatcher Dispatcher { get; }
|
||||
/// <summary>The cached-call lifecycle bridge wired to the forwarder.</summary>
|
||||
public CachedCallLifecycleBridge Bridge { get; }
|
||||
/// <summary>The stub site-stream audit client that delivers packets directly to the ingest actor.</summary>
|
||||
public DirectActorSiteStreamAuditClient StubClient { get; }
|
||||
/// <summary>The central audit ingest actor.</summary>
|
||||
public IActorRef IngestActor { get; }
|
||||
/// <summary>The service provider backing the central repositories.</summary>
|
||||
public IServiceProvider ServiceProvider { get; }
|
||||
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
@@ -64,6 +74,10 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
||||
private readonly ServiceProvider _trackingLocalDbProvider;
|
||||
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(
|
||||
MsSqlMigrationFixture fixture,
|
||||
Akka.TestKit.Xunit2.TestKit testKit,
|
||||
@@ -133,15 +147,23 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
||||
/// dispatcher (the bridge's hooks fire only for S&F retry-loop
|
||||
/// attempts; submit-row emission happens at the script call site).
|
||||
/// </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) =>
|
||||
Dispatcher.ForwardAsync(submit, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Convenience: route a per-attempt or terminal outcome through the bridge.
|
||||
/// </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) =>
|
||||
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()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
@@ -173,6 +195,8 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
||||
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()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
+12
-16
@@ -12,8 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
/// Shared component-level <see cref="ISiteStreamAuditClient"/> test double that
|
||||
/// short-circuits the gRPC wire and forwards each batch directly to a central
|
||||
/// <see cref="AuditLog.Central.AuditLogIngestActor"/> via Akka <see cref="Futures.Ask"/>.
|
||||
/// Lives under <c>Integration/Infrastructure/</c> so both the M2 sync-call and
|
||||
/// M3 cached-call end-to-end suites can reuse it.
|
||||
/// Lives under <c>Integration/Infrastructure/</c> so both the sync-call and
|
||||
/// cached-call end-to-end suites can reuse it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -27,8 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="FailNextCallCount"/> arms a deterministic number of failures
|
||||
/// before the stub recovers; it applies to BOTH RPCs because the M2 sync-call
|
||||
/// retry behaviour and the M3 cached-telemetry retry behaviour share a single
|
||||
/// before the stub recovers; it applies to BOTH RPCs because the sync-call
|
||||
/// retry behaviour and the cached-telemetry retry behaviour share a single
|
||||
/// SiteAuditTelemetryActor drain. Tests that need to differentiate per-RPC
|
||||
/// failures should reach for a per-test wrapper rather than extending this
|
||||
/// shared infrastructure.
|
||||
@@ -41,6 +41,8 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
private int _callCount;
|
||||
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)
|
||||
{
|
||||
_ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor));
|
||||
@@ -69,6 +71,7 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
/// </summary>
|
||||
public int CachedTelemetryCallCount => Volatile.Read(ref _cachedTelemetryCallCount);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
@@ -106,18 +109,11 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
return ack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M3 dual-write path: decode each <see cref="CachedTelemetryPacket"/> into
|
||||
/// the paired (<see cref="AuditEvent"/>, <see cref="SiteCall"/>) entry and
|
||||
/// Ask the central ingest actor with an <see cref="IngestCachedTelemetryCommand"/>.
|
||||
/// The accepted EventIds returned by the actor's dual-write transaction map
|
||||
/// 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>
|
||||
// Dual-write path: decodes each CachedTelemetryPacket into the paired (AuditEvent, SiteCall)
|
||||
// entry via the shared AuditEventDtoMapper.FromDto and SiteCallDtoMapper.FromDto (the same
|
||||
// canonical mappers the production SiteStreamGrpcServer uses), then Asks the central ingest
|
||||
// actor with an IngestCachedTelemetryCommand; the accepted EventIds map back into the proto ack.
|
||||
/// <inheritdoc />
|
||||
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _cachedTelemetryCallCount);
|
||||
|
||||
+14
-2
@@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <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 production <see cref="CentralAuditWriter"/> onto the real
|
||||
/// <see cref="AuditLogRepository"/> against the per-class
|
||||
@@ -51,6 +51,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -105,13 +107,17 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
||||
private readonly Queue<DeliveryOutcome> _outcomes;
|
||||
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)
|
||||
{
|
||||
_outcomes = new Queue<DeliveryOutcome>(outcomes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
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
|
||||
/// means a transient outcome's <c>NextAttemptAt</c> is immediately due —
|
||||
/// 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
|
||||
/// must be a strictly positive value (1 ms is fine for tests).
|
||||
/// </summary>
|
||||
@@ -242,6 +248,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
||||
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]
|
||||
public async Task NotifyDispatcher_FailThenSuccess_Emits_TwoAttempts_OneDelivered_Terminal()
|
||||
{
|
||||
@@ -319,6 +327,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
||||
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]
|
||||
public async Task NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds()
|
||||
{
|
||||
@@ -394,8 +404,10 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
||||
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
private int _attemptCount;
|
||||
/// <summary>Gets the number of write attempts made against this writer.</summary>
|
||||
public int AttemptCount => Volatile.Read(ref _attemptCount);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _attemptCount);
|
||||
|
||||
+18
-5
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <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
|
||||
/// (<see cref="SqliteAuditWriter"/>) and the central <see cref="SiteAuditReconciliationActor"/>
|
||||
/// with an <see cref="AuditLogIngestActor"/> backed by the real
|
||||
@@ -39,7 +39,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
/// the real repository.
|
||||
/// </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
|
||||
/// retrofitting the shared harness with a code path it doesn't otherwise
|
||||
/// need.
|
||||
@@ -49,6 +49,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -77,19 +79,23 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
private sealed class QueueBackedPullClient : IPullAuditEventsClient
|
||||
{
|
||||
private readonly ISiteAuditQueue _siteQueue;
|
||||
/// <summary>Gets the number of times <c>PullAsync</c> has been invoked.</summary>
|
||||
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)
|
||||
{
|
||||
_siteQueue = siteQueue ?? throw new ArgumentNullException(nameof(siteQueue));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PullAuditEventsResponse> PullAsync(
|
||||
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
||||
{
|
||||
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
|
||||
// is retired FIRST; the rows this call serves are NOT retired, because
|
||||
// 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 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;
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(_sites);
|
||||
}
|
||||
@@ -208,6 +217,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
// 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]
|
||||
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));
|
||||
|
||||
// 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
|
||||
// counts rows and so a step-5 failure cannot leak them (register row 95).
|
||||
// read-back, so nothing is still writing to the shared fixture DB while the read-back
|
||||
// counts rows and so a read-back failure cannot leak them (register row 95).
|
||||
StopActor(reconciliationActor);
|
||||
StopActor(ingest);
|
||||
|
||||
@@ -321,6 +332,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
||||
// 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]
|
||||
public async Task ReconciliationPull_Idempotent_Across_Two_Cycles()
|
||||
{
|
||||
|
||||
+19
-2
@@ -40,7 +40,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -101,6 +101,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -126,6 +128,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
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]
|
||||
public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId()
|
||||
{
|
||||
@@ -507,7 +511,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
async () =>
|
||||
{
|
||||
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
|
||||
// them in via the dedicated read surface to keep the durability
|
||||
// assertion covering EVERY expected Kind.
|
||||
@@ -534,8 +538,10 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
/// </summary>
|
||||
private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification notification,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -568,6 +574,12 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
/// </summary>
|
||||
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(
|
||||
string siteId,
|
||||
IExternalSystemClient externalClient,
|
||||
@@ -582,6 +594,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
_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(
|
||||
string siteId, RouteToCallRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -626,16 +640,19 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
request.CorrelationId, true, "routed-ok", null, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
|
||||
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
|
||||
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
// WaitForAttribute is not part of this fixture's routed-Call audit scenario;
|
||||
// mirror the other non-Call methods (unexercised here).
|
||||
/// <inheritdoc />
|
||||
public Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
|
||||
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle F (#23 M6-T12) end-to-end tests for the
|
||||
/// End-to-end tests for the
|
||||
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service running
|
||||
/// the real EF/MSSQL <see cref="AuditLogPartitionMaintenance"/> against the
|
||||
/// per-class <see cref="MsSqlMigrationFixture"/>. The migration seeds
|
||||
@@ -30,6 +30,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -116,6 +118,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// 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]
|
||||
public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered()
|
||||
{
|
||||
@@ -162,6 +166,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// 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]
|
||||
public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries()
|
||||
{
|
||||
@@ -218,6 +224,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// 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]
|
||||
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;
|
||||
|
||||
/// <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
|
||||
/// INSERTs that bypass the standard repository ingest path (so the seed
|
||||
/// timestamps are explicit), drives <see cref="AuditLogPurgeActor"/> against
|
||||
@@ -46,6 +46,8 @@ public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -71,11 +73,11 @@ public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
string kind = "ApiCall")
|
||||
{
|
||||
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
|
||||
// DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive.
|
||||
// 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.
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO dbo.AuditLog
|
||||
@@ -111,7 +113,7 @@ VALUES
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// is absent (WP2.2 — <c>AlignAuditLogEventIdUniqueness</c>).
|
||||
/// is absent (<c>AlignAuditLogEventIdUniqueness</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The purge used to bracket its SWITCH with a DROP/CREATE of
|
||||
@@ -164,7 +166,7 @@ SELECT
|
||||
/// <para>
|
||||
/// Deferred-work register row 82. Every actor these tests create ticks every
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -172,9 +174,9 @@ SELECT
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// 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
|
||||
/// actor inside the test that created it removes the dependency on shutdown timing
|
||||
/// altogether.
|
||||
@@ -228,9 +230,9 @@ SELECT
|
||||
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
|
||||
|
||||
return (
|
||||
Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in Jan-2026 partition → PURGED
|
||||
Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in Apr-2026 partition → KEPT
|
||||
Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in Jun-2026 partition → KEPT
|
||||
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 April 2026 partition → KEPT
|
||||
Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in June 2026 partition → KEPT
|
||||
RetentionDays: retentionDays
|
||||
);
|
||||
}
|
||||
@@ -239,6 +241,8 @@ SELECT
|
||||
// 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]
|
||||
public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept()
|
||||
{
|
||||
@@ -283,7 +287,7 @@ SELECT
|
||||
|
||||
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 matched = probe.FishForMessage<AuditLogPurgedEvent>(
|
||||
isMessage: m => m.MonthBoundary == janBoundary,
|
||||
@@ -313,14 +317,16 @@ SELECT
|
||||
// 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]
|
||||
public async Task EndToEnd_AlignedUniqueness_AfterPurge()
|
||||
{
|
||||
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
|
||||
// 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
|
||||
// nothing to drop before the SWITCH and nothing to rebuild after it.
|
||||
var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
@@ -370,14 +376,16 @@ SELECT
|
||||
// 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]
|
||||
public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge()
|
||||
{
|
||||
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
|
||||
// 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.
|
||||
// RetentionDays is computed dynamically so the threshold always lands near
|
||||
// 2026-01-20 (see SeedOccurredAt()).
|
||||
@@ -455,11 +463,11 @@ SELECT
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3)
|
||||
// 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <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,
|
||||
/// 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
|
||||
@@ -474,6 +482,7 @@ SELECT
|
||||
/// the append-only writer-role DENYs do not bind (the role granularity is exercised
|
||||
/// in the repository/migration tests).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows()
|
||||
{
|
||||
@@ -538,17 +547,18 @@ SELECT
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 5. SwitchOut_WithExplicitCommandTimeout_Succeeds (Task 4)
|
||||
// 5. SwitchOut_WithExplicitCommandTimeout_Succeeds
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task SwitchOut_WithExplicitCommandTimeout_Succeeds()
|
||||
{
|
||||
|
||||
+10
-2
@@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
|
||||
/// <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
|
||||
/// an in-memory SQLite database; the <see cref="SiteAuditTelemetryActor"/> drains
|
||||
/// Pending rows and pushes them through a stub <see cref="ISiteStreamAuditClient"/>
|
||||
@@ -30,7 +30,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// real SQLite writer, and the real MSSQL repository — without standing up a
|
||||
/// Kestrel host or two-cluster topology.
|
||||
@@ -51,6 +51,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
||||
{
|
||||
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)
|
||||
{
|
||||
_fixture = fixture;
|
||||
@@ -129,6 +131,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
||||
FastTelemetryOptions(),
|
||||
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]
|
||||
public async Task EndToEnd_OneWrittenEvent_Reaches_Central_AuditLog_Within_Reasonable_Time()
|
||||
{
|
||||
@@ -174,6 +178,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
||||
}, 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]
|
||||
public async Task EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds()
|
||||
{
|
||||
@@ -246,6 +252,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
||||
}, 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]
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// M6 "KPI History & Trends" (K8) coverage for <see cref="AuditLogKpiSampleSource"/> —
|
||||
/// "KPI History & Trends" coverage for <see cref="AuditLogKpiSampleSource"/> —
|
||||
/// 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
|
||||
/// 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);
|
||||
|
||||
/// <summary>Verifies that the sample source reports the AuditLog source name.</summary>
|
||||
[Fact]
|
||||
public void Source_is_AuditLog()
|
||||
{
|
||||
@@ -28,6 +29,8 @@ public class AuditLogKpiSampleSourceTests
|
||||
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]
|
||||
public async Task CollectAsync_emits_three_global_samples_mapped_from_snapshot()
|
||||
{
|
||||
@@ -64,6 +67,8 @@ public class AuditLogKpiSampleSourceTests
|
||||
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]
|
||||
public async Task CollectAsync_anchors_the_window_on_capturedAtUtc()
|
||||
{
|
||||
@@ -83,6 +88,8 @@ public class AuditLogKpiSampleSourceTests
|
||||
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]
|
||||
public async Task CollectAsync_returns_empty_when_snapshot_is_null()
|
||||
{
|
||||
@@ -98,6 +105,8 @@ public class AuditLogKpiSampleSourceTests
|
||||
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]
|
||||
public async Task CollectAsync_uses_backlog_provider_when_present()
|
||||
{
|
||||
@@ -127,11 +136,13 @@ public class AuditLogKpiSampleSourceTests
|
||||
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]
|
||||
public async Task CollectAsync_falls_back_to_snapshot_backlog_when_provider_absent()
|
||||
{
|
||||
// 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(
|
||||
TotalEventsLastHour: 10,
|
||||
ErrorEventsLastHour: 1,
|
||||
|
||||
+27
-2
@@ -10,8 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the fail-closed contract for unavailable redactor patterns
|
||||
/// (Gitea #35).
|
||||
/// Guards the fail-closed contract for unavailable redactor patterns.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The defect.</b> <see cref="AuditRegexCache"/> rejects a pattern that is
|
||||
@@ -118,6 +117,7 @@ public class AuditRedactorFailClosedTests
|
||||
Assert.Equal(0, counter.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unavailable global body redactor suppresses the payload rather than emitting it raw.</summary>
|
||||
[Fact]
|
||||
public void UnavailableGlobalBodyRedactor_SuppressesPayload_RatherThanEmittingItRaw()
|
||||
{
|
||||
@@ -152,6 +152,7 @@ public class AuditRedactorFailClosedTests
|
||||
Assert.Equal(1, counter.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unavailable per-target body redactor suppresses the payload.</summary>
|
||||
[Fact]
|
||||
public void UnavailablePerTargetBodyRedactor_SuppressesPayload()
|
||||
{
|
||||
@@ -267,16 +268,27 @@ public class AuditRedactorFailClosedTests
|
||||
private sealed class CountingCounter : IAuditRedactionFailureCounter
|
||||
{
|
||||
private int _count;
|
||||
/// <summary>Gets the number of redaction failures counted so far.</summary>
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
/// <inheritdoc />
|
||||
public void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
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;
|
||||
/// <summary>Gets the current options value.</summary>
|
||||
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;
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -286,10 +298,14 @@ public class AuditRedactorFailClosedTests
|
||||
private readonly List<Action<AuditLogOptions, string?>> _listeners = new();
|
||||
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;
|
||||
|
||||
/// <summary>Gets the number of times <c>CurrentValue</c> has been read.</summary>
|
||||
public int CurrentValueReads => Volatile.Read(ref _reads);
|
||||
|
||||
/// <summary>Gets the current options value.</summary>
|
||||
public AuditLogOptions CurrentValue
|
||||
{
|
||||
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;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
lock (_listeners)
|
||||
@@ -310,6 +332,8 @@ public class AuditRedactorFailClosedTests
|
||||
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)
|
||||
{
|
||||
_value = value;
|
||||
@@ -326,6 +350,7 @@ public class AuditRedactorFailClosedTests
|
||||
|
||||
private sealed class Noop : IDisposable
|
||||
{
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for
|
||||
/// Tests for
|
||||
/// <see cref="SafeDefaultAuditRedactor"/> — the minimal always-safe
|
||||
/// <see cref="ZB.MOM.WW.Audit.IAuditRedactor"/> fallback.
|
||||
/// Header-only scrub of the always-sensitive default headers inside
|
||||
@@ -35,6 +35,7 @@ public class SafeDefaultAuditRedactorTests
|
||||
private static AuditDetails Details(AuditEvent evt) =>
|
||||
AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
||||
|
||||
/// <summary>Verifies that the default sensitive headers are redacted in the request summary.</summary>
|
||||
[Fact]
|
||||
public void Redacts_DefaultSensitiveHeaders_InRequestSummary()
|
||||
{
|
||||
@@ -48,6 +49,7 @@ public class SafeDefaultAuditRedactorTests
|
||||
Assert.Contains("Content-Type: application/json", d.RequestSummary!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default sensitive headers are redacted in the response summary.</summary>
|
||||
[Fact]
|
||||
public void Redacts_DefaultSensitiveHeaders_InResponseSummary()
|
||||
{
|
||||
@@ -61,6 +63,7 @@ public class SafeDefaultAuditRedactorTests
|
||||
Assert.Contains("X-Other: ok", d.ResponseSummary!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that header names are matched case-insensitively when redacting.</summary>
|
||||
[Fact]
|
||||
public void CaseInsensitive_HeaderName_Redacted()
|
||||
{
|
||||
@@ -72,6 +75,7 @@ public class SafeDefaultAuditRedactorTests
|
||||
Assert.DoesNotContain("x-y-z", Details(result).RequestSummary!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-sensitive header is preserved unchanged.</summary>
|
||||
[Fact]
|
||||
public void NonSensitiveHeader_Preserved()
|
||||
{
|
||||
@@ -84,6 +88,7 @@ public class SafeDefaultAuditRedactorTests
|
||||
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]
|
||||
public void NullDetails_FastPath_ReturnsSameInstance()
|
||||
{
|
||||
@@ -102,6 +107,7 @@ public class SafeDefaultAuditRedactorTests
|
||||
Assert.Same(evt, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that malformed details JSON never causes the redactor to throw.</summary>
|
||||
[Fact]
|
||||
public void MalformedDetailsJson_NeverThrows()
|
||||
{
|
||||
|
||||
+49
-1
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||
|
||||
/// <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="IAuditRedactor"/> implementation. Covers the header-redaction /
|
||||
/// body-regex / SQL-param / safety-net / truncation pipeline operating on
|
||||
@@ -20,6 +20,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||
/// </summary>
|
||||
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]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -35,6 +37,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues()
|
||||
{
|
||||
@@ -101,6 +104,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
|
||||
// ---- Header redaction (ports HeaderRedactionTests) ---------------------
|
||||
|
||||
/// <summary>Verifies that an Authorization bearer header is redacted.</summary>
|
||||
[Fact]
|
||||
public void HeaderRedaction_AuthorizationBearer_Redacted()
|
||||
{
|
||||
@@ -116,6 +120,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.Contains("application/json", d.RequestSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that header redaction is case-insensitive, so a lowercase authorization header is redacted.</summary>
|
||||
[Fact]
|
||||
public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
|
||||
{
|
||||
@@ -127,6 +132,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.Contains("\"authorization\":\"<redacted>\"", Details(result).RequestSummary!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a custom redact list redacts a custom header name.</summary>
|
||||
[Fact]
|
||||
public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
|
||||
{
|
||||
@@ -141,6 +147,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.Contains("Bearer keep-me", d.RequestSummary!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a request summary that is not JSON passes through header redaction unchanged.</summary>
|
||||
[Fact]
|
||||
public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
|
||||
{
|
||||
@@ -153,6 +160,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
|
||||
// ---- Body regex redaction (ports BodyRegexRedactionTests) --------------
|
||||
|
||||
/// <summary>Verifies that the global regex redacts a Hunter-style password value.</summary>
|
||||
[Fact]
|
||||
public void GlobalRegex_HunterPassword_Redacted()
|
||||
{
|
||||
@@ -170,6 +178,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.Contains("alice", d.RequestSummary!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the body regex is applied to the error detail and the extra field.</summary>
|
||||
[Fact]
|
||||
public void BodyRegex_AppliesToErrorDetailAndExtra()
|
||||
{
|
||||
@@ -190,6 +199,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.Contains("<redacted>", d.Extra!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a per-target regex is applied only to the matching target.</summary>
|
||||
[Fact]
|
||||
public void PerTargetRegex_OnlyAppliedToMatchingTarget()
|
||||
{
|
||||
@@ -213,6 +223,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.Equal(input, Details(unmatched).RequestSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a field is left unchanged when no regex is configured.</summary>
|
||||
[Fact]
|
||||
public void NoRegexConfigured_FieldUnchanged()
|
||||
{
|
||||
@@ -223,6 +234,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
|
||||
{
|
||||
@@ -257,6 +269,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SQL parameters are kept verbatim when no opt-in is configured.</summary>
|
||||
[Fact]
|
||||
public void NoOptIn_ParamsVerbatim_Unchanged()
|
||||
{
|
||||
@@ -272,6 +285,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers()
|
||||
{
|
||||
@@ -298,6 +312,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void NonDbOutboundChannel_NotAffected()
|
||||
{
|
||||
@@ -320,6 +335,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
|
||||
// ---- 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]
|
||||
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
|
||||
{
|
||||
@@ -334,6 +350,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap()
|
||||
{
|
||||
@@ -348,6 +365,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.False(d.PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a 70 KB request summary on a failed row is truncated to 64 KB.</summary>
|
||||
[Fact]
|
||||
public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB()
|
||||
{
|
||||
@@ -362,6 +380,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
|
||||
{
|
||||
@@ -379,6 +398,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
|
||||
{
|
||||
@@ -391,6 +411,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.False(Details(result).PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a submitted status is treated as a success and uses the default cap.</summary>
|
||||
[Fact]
|
||||
public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap()
|
||||
{
|
||||
@@ -406,6 +427,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.True(d.PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a large inbound API body uses the inbound cap rather than the default cap.</summary>
|
||||
[Fact]
|
||||
public void ApiInbound_LargeBody_UsesInboundCap_NotDefault()
|
||||
{
|
||||
@@ -420,6 +442,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
|
||||
{
|
||||
@@ -435,6 +458,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
|
||||
{
|
||||
@@ -453,6 +477,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
Assert.True(d.PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a details payload that was already marked truncated stays truncated.</summary>
|
||||
[Fact]
|
||||
public void ExistingDetailsPayloadTruncated_RemainsTrue()
|
||||
{
|
||||
@@ -467,6 +492,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
|
||||
// ---- Target length cap -------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a target over the cap is truncated at a byte boundary.</summary>
|
||||
[Fact]
|
||||
public void Target_OverCap_Truncated_ToByteBoundary()
|
||||
{
|
||||
@@ -483,6 +509,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
|
||||
// ---- Fast-path ---------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the fast path returns the same instance when the details are null and the target is short.</summary>
|
||||
[Fact]
|
||||
public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance()
|
||||
{
|
||||
@@ -502,6 +529,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance()
|
||||
{
|
||||
@@ -523,6 +551,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
|
||||
// ---- Never-throws safety net -------------------------------------------
|
||||
|
||||
/// <summary>Verifies that malformed details JSON never throws and yields a safe copy.</summary>
|
||||
[Fact]
|
||||
public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy()
|
||||
{
|
||||
@@ -545,6 +574,7 @@ public class ScadaBridgeAuditRedactorTests
|
||||
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]
|
||||
public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted()
|
||||
{
|
||||
@@ -601,7 +631,9 @@ public class ScadaBridgeAuditRedactorTests
|
||||
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
|
||||
{
|
||||
private int _count;
|
||||
/// <summary>Gets the number of recorded failures.</summary>
|
||||
public int Count => _count;
|
||||
/// <inheritdoc />
|
||||
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
@@ -609,9 +641,18 @@ public class ScadaBridgeAuditRedactorTests
|
||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
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;
|
||||
/// <summary>Gets the options value this monitor serves.</summary>
|
||||
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;
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -622,9 +663,16 @@ public class ScadaBridgeAuditRedactorTests
|
||||
/// </summary>
|
||||
private sealed class ThrowingMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
/// <summary>Gets the options value this monitor serves.</summary>
|
||||
public AuditLogOptions CurrentValue =>
|
||||
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();
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <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
|
||||
/// <see cref="RingBufferFallback"/>, and an
|
||||
/// <see cref="IAuditWriteFailureCounter"/> health counter.
|
||||
@@ -28,9 +28,12 @@ public class FallbackAuditWriterTests
|
||||
/// <summary>Flip-switch primary writer mock.</summary>
|
||||
private sealed class FlipSwitchPrimary : IAuditWriter
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether the next write fails.</summary>
|
||||
public bool FailNext { get; set; }
|
||||
/// <summary>Gets the events the primary has accepted.</summary>
|
||||
public List<AuditEvent> Written { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
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]
|
||||
public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess()
|
||||
{
|
||||
@@ -59,6 +64,8 @@ public class FallbackAuditWriterTests
|
||||
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]
|
||||
public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite()
|
||||
{
|
||||
@@ -93,6 +100,8 @@ public class FallbackAuditWriterTests
|
||||
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]
|
||||
public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty()
|
||||
{
|
||||
@@ -112,6 +121,8 @@ public class FallbackAuditWriterTests
|
||||
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]
|
||||
public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure()
|
||||
{
|
||||
|
||||
+5
-2
@@ -5,17 +5,18 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle C (M5-T7) — the <see cref="HealthMetricsAuditRedactionFailureCounter"/>
|
||||
/// The <see cref="HealthMetricsAuditRedactionFailureCounter"/>
|
||||
/// adapter is the production binding for
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/> on
|
||||
/// site nodes; it forwards every <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
|
||||
/// redactor over-redaction event into the shared
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public class HealthMetricsAuditRedactionFailureCounterTests
|
||||
{
|
||||
/// <summary>Verifies the case: increment, routes, to, collector, increment Audit Redaction Failure.</summary>
|
||||
[Fact]
|
||||
public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure()
|
||||
{
|
||||
@@ -27,6 +28,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests
|
||||
collector.Received(1).IncrementAuditRedactionFailure();
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: increment, multiple, calls, route, to, collector, each, time.</summary>
|
||||
[Fact]
|
||||
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests
|
||||
collector.Received(3).IncrementAuditRedactionFailure();
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: construction, with, null, collector, throws, argument Null Exception.</summary>
|
||||
[Fact]
|
||||
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
|
||||
{
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle G (M2-T11) — the <see cref="HealthMetricsAuditWriteFailureCounter"/>
|
||||
/// The <see cref="HealthMetricsAuditWriteFailureCounter"/>
|
||||
/// adapter is the production binding for <see cref="IAuditWriteFailureCounter"/>
|
||||
/// on site nodes; it forwards every FallbackAuditWriter primary failure into
|
||||
/// the shared <see cref="ISiteHealthCollector"/> so the site health report
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
public class HealthMetricsAuditWriteFailureCounterTests
|
||||
{
|
||||
/// <summary>Verifies that a single increment is routed to the collector as a site audit write failure.</summary>
|
||||
[Fact]
|
||||
public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public class HealthMetricsAuditWriteFailureCounterTests
|
||||
collector.Received(1).IncrementSiteAuditWriteFailures();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that each of several increments is routed to the collector.</summary>
|
||||
[Fact]
|
||||
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
|
||||
{
|
||||
@@ -37,6 +39,7 @@ public class HealthMetricsAuditWriteFailureCounterTests
|
||||
collector.Received(3).IncrementSiteAuditWriteFailures();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that constructing the counter with a null collector throws an <see cref="ArgumentNullException"/>.</summary>
|
||||
[Fact]
|
||||
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;
|
||||
|
||||
/// <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
|
||||
/// primary SQLite writer is throwing.
|
||||
/// </summary>
|
||||
@@ -23,6 +23,8 @@ public class RingBufferFallbackTests
|
||||
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]
|
||||
public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce()
|
||||
{
|
||||
@@ -51,6 +53,8 @@ public class RingBufferFallbackTests
|
||||
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]
|
||||
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]
|
||||
public void TryEnqueue_AllSucceeds_ReturnsTrue()
|
||||
{
|
||||
|
||||
+27
-2
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <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
|
||||
/// old hard-coded 30 s constant.
|
||||
/// </summary>
|
||||
@@ -26,6 +26,8 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
explicitInterval,
|
||||
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]
|
||||
public async Task StopAsync_WhileProbeInFlight_LoopCompletesCleanly()
|
||||
{
|
||||
@@ -62,10 +64,12 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
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]
|
||||
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
|
||||
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
||||
// escape an IHostedService aborts the host's whole shutdown sequence.
|
||||
@@ -79,6 +83,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
reporter.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the poll cadence comes from the options when one is configured.</summary>
|
||||
[Fact]
|
||||
public void Cadence_ComesFromOptions_WhenConfigured()
|
||||
{
|
||||
@@ -89,6 +94,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-positive configured interval falls back to the default cadence.</summary>
|
||||
[Fact]
|
||||
public void Cadence_FallsBackToDefault_WhenOptionsNonPositive()
|
||||
{
|
||||
@@ -99,6 +105,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default cadence is used when no options are supplied.</summary>
|
||||
[Fact]
|
||||
public void Cadence_FallsBackToDefault_WhenNoOptions()
|
||||
{
|
||||
@@ -107,6 +114,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an explicitly supplied interval takes precedence over the options value.</summary>
|
||||
[Fact]
|
||||
public void ExplicitInterval_WinsOverOptions()
|
||||
{
|
||||
@@ -119,6 +127,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
|
||||
// ----- 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]
|
||||
public void StalePendingBacklog_IsWarned_ThenRateLimited()
|
||||
{
|
||||
@@ -143,6 +152,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
Assert.Contains("pending", warning.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a fresh or empty pending backlog does not log a warning.</summary>
|
||||
[Fact]
|
||||
public void FreshOrEmptyPendingBacklog_IsNotWarned()
|
||||
{
|
||||
@@ -163,12 +173,27 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
/// <summary>Captures log entries so the stale-pending signal can be asserted.</summary>
|
||||
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();
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
|
||||
@@ -3,13 +3,16 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <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:
|
||||
/// retention days are clamped to [1, 90]; the purge interval is clamped away from
|
||||
/// TimeSpan.Zero (an Akka/Timer spin footgun).
|
||||
/// </summary>
|
||||
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]
|
||||
[InlineData(0, 1)] // clamp floor
|
||||
[InlineData(7, 7)] // default passthrough
|
||||
@@ -17,6 +20,7 @@ public class SiteAuditRetentionOptionsTests
|
||||
public void ResolvedRetentionDays_Clamps(int configured, int expected)
|
||||
=> Assert.Equal(expected, new SiteAuditRetentionOptions { RetentionDays = configured }.ResolvedRetentionDays);
|
||||
|
||||
/// <summary>Verifies that defaults, are, seven days, daily purge, five minute initial delay.</summary>
|
||||
[Fact]
|
||||
public void Defaults_Are_SevenDays_DailyPurge_FiveMinuteInitialDelay()
|
||||
{
|
||||
@@ -26,6 +30,7 @@ public class SiteAuditRetentionOptionsTests
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), o.InitialDelay);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolved purge interval, clamps zero to one minute.</summary>
|
||||
[Fact]
|
||||
public void ResolvedPurgeInterval_ClampsZeroToOneMinute()
|
||||
=> Assert.Equal(TimeSpan.FromMinutes(1),
|
||||
|
||||
+4
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
public class SiteAuditRetentionOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that the default options pass validation.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retention period of zero days fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroRetentionDays_Fails()
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a purge interval of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroPurgeInterval_Fails()
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative initial delay fails validation.</summary>
|
||||
[Fact]
|
||||
public void NegativeInitialDelay_Fails()
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <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
|
||||
/// <see cref="SiteAuditRetentionOptions.InitialDelay"/>, then every
|
||||
/// <see cref="SiteAuditRetentionOptions.ResolvedPurgeInterval"/>, passing a cutoff of
|
||||
@@ -19,10 +19,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
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]
|
||||
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
|
||||
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
||||
// escape an IHostedService aborts the host's whole shutdown sequence.
|
||||
@@ -43,6 +45,8 @@ public class SiteAuditRetentionServiceTests
|
||||
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]
|
||||
public async Task Tick_Purges_With_RetentionCutoff()
|
||||
{
|
||||
@@ -66,6 +70,8 @@ public class SiteAuditRetentionServiceTests
|
||||
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]
|
||||
public async Task Tick_SwallowsExceptions_AndKeepsTicking()
|
||||
{
|
||||
@@ -87,6 +93,8 @@ public class SiteAuditRetentionServiceTests
|
||||
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]
|
||||
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>
|
||||
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(
|
||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
|
||||
=> throw new NotSupportedException("not exercised by the retention service");
|
||||
@@ -134,13 +149,17 @@ public class SiteAuditRetentionServiceTests
|
||||
private readonly ConcurrentQueue<DateTime> _purgeCalls = new();
|
||||
private int _calls;
|
||||
|
||||
/// <summary>Gets the cutoffs passed to each purge call, in call order.</summary>
|
||||
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; }
|
||||
|
||||
/// <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>
|
||||
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)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _calls);
|
||||
@@ -158,19 +177,41 @@ public class SiteAuditRetentionServiceTests
|
||||
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)
|
||||
=> 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)
|
||||
=> throw new NotSupportedException();
|
||||
// Test stub: Not supported by this stub.
|
||||
/// <inheritdoc />
|
||||
public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
|
||||
=> 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(
|
||||
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
// Test stub: Not supported by this stub.
|
||||
/// <inheritdoc />
|
||||
public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
// Test stub: Not supported by this stub.
|
||||
/// <inheritdoc />
|
||||
public Task<int> MarkReconciledUpToAsync(DateTime sinceUtc, string? afterId, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
// Test stub: Not supported by this stub.
|
||||
/// <inheritdoc />
|
||||
public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
+5
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
public class SiteAuditTelemetryOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that default options are valid.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -18,6 +19,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero batch size fails.</summary>
|
||||
[Fact]
|
||||
public void ZeroBatchSize_Fails()
|
||||
{
|
||||
@@ -28,6 +30,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero busy interval fails.</summary>
|
||||
[Fact]
|
||||
public void ZeroBusyInterval_Fails()
|
||||
{
|
||||
@@ -38,6 +41,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero idle interval fails.</summary>
|
||||
[Fact]
|
||||
public void ZeroIdleInterval_Fails()
|
||||
{
|
||||
@@ -48,6 +52,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that idle shorter than busy fails.</summary>
|
||||
[Fact]
|
||||
public void IdleShorterThanBusy_Fails()
|
||||
{
|
||||
|
||||
+14
-2
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <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>
|
||||
/// polls every 30 s and pushes onto the site health report as
|
||||
/// <c>SiteAuditBacklog</c>.
|
||||
@@ -18,6 +18,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
/// <summary>Creates a unique temporary database path for each test.</summary>
|
||||
public SqliteAuditWriterBacklogStatsTests()
|
||||
{
|
||||
// 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");
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by this test class.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
@@ -52,6 +54,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
kind: AuditKind.ApiCall,
|
||||
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]
|
||||
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
|
||||
{
|
||||
@@ -70,6 +74,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
$"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]
|
||||
public async Task Pending_5_Returns_5()
|
||||
{
|
||||
@@ -85,6 +91,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
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]
|
||||
public async Task OldestPending_Is_Earliest_OccurredAtUtc()
|
||||
{
|
||||
@@ -110,10 +118,12 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
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]
|
||||
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
|
||||
// backlog growing to hundreds of thousands of rows a COUNT(*)+MIN
|
||||
// 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}");
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task OnDiskBytes_ReturnsFileSize()
|
||||
{
|
||||
|
||||
+5
-1
@@ -14,7 +14,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <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
|
||||
/// <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
|
||||
@@ -102,6 +102,8 @@ public class SqliteAuditWriterCommitFailureTests
|
||||
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]
|
||||
public async Task FlushBatch_MidBatchInsertFailure_FaultsEveryRowInBatch_NotJustTheFailingRow()
|
||||
{
|
||||
@@ -152,6 +154,8 @@ public class SqliteAuditWriterCommitFailureTests
|
||||
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]
|
||||
public async Task FallbackAuditWriter_MidBatchInsertFailure_RingsEveryFaultedRow_AndIncrementsFailureCounterForEach()
|
||||
{
|
||||
|
||||
+7
-2
@@ -10,8 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// non-positive value has a documented fall-back-to-30s contract.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation
|
||||
/// WP1.2): it defaults to <c>""</c> (no CWD-relative default — mirrors
|
||||
/// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation): it defaults to <c>""</c> (no CWD-relative default — mirrors
|
||||
/// <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
|
||||
/// the writer. The Site-only requirement is enforced pre-host by
|
||||
@@ -20,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </remarks>
|
||||
public class SqliteAuditWriterOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that the default options pass validation.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -29,6 +29,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a channel capacity of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroChannelCapacity_Fails()
|
||||
{
|
||||
@@ -39,6 +40,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
||||
f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch size of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroBatchSize_Fails()
|
||||
{
|
||||
@@ -49,6 +51,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
||||
f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a flush interval of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroFlushInterval_Fails()
|
||||
{
|
||||
@@ -59,6 +62,8 @@ public class SqliteAuditWriterOptionsValidatorTests
|
||||
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]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
|
||||
+13
@@ -19,6 +19,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
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);
|
||||
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
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,
|
||||
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]
|
||||
[InlineData(1)]
|
||||
[InlineData(64)]
|
||||
@@ -56,6 +60,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
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]
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows()
|
||||
{
|
||||
@@ -100,6 +108,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
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]
|
||||
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
|
||||
/// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ServesOutboundRows_NoChannelPredicate()
|
||||
{
|
||||
@@ -171,6 +182,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
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]
|
||||
public async Task Guards_LimitAndIdCap()
|
||||
{
|
||||
|
||||
@@ -22,12 +22,14 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
/// <summary>Initializes the test class with a unique temporary database path.</summary>
|
||||
public SqliteAuditWriterRetentionTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(),
|
||||
$"audit-retention-{Guid.NewGuid():N}.db");
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
@@ -58,6 +60,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
||||
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]
|
||||
public async Task PurgeExpired_DeletesForwardedAndReconciled_OlderThanCutoff()
|
||||
{
|
||||
@@ -80,6 +84,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
||||
Assert.Equal(1, stats.PendingCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that purging expired rows is idempotent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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;
|
||||
|
||||
/// <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 +
|
||||
/// 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
|
||||
@@ -80,6 +80,7 @@ public class SqliteAuditWriterSchemaTests
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
public void PRAGMA_auto_vacuum_Is_INCREMENTAL()
|
||||
{
|
||||
@@ -265,6 +269,8 @@ public class SqliteAuditWriterSchemaTests
|
||||
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]
|
||||
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 ----- //
|
||||
|
||||
/// <summary>Verifies that write async, persists, source node, field.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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]
|
||||
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]
|
||||
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;
|
||||
|
||||
/// <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
|
||||
/// writer's per-event canonical(<c>audit_event</c>) + sidecar
|
||||
/// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the
|
||||
@@ -87,7 +87,7 @@ public class SqliteAuditWriterWriteTests
|
||||
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
|
||||
// audit_event and writes a Pending sidecar row into audit_forward_state, with
|
||||
// IsCachedKind precomputed from the event's Kind. Reads recompose the canonical
|
||||
@@ -120,6 +120,8 @@ public class SqliteAuditWriterWriteTests
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
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]
|
||||
public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending()
|
||||
{
|
||||
@@ -140,6 +142,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read()
|
||||
{
|
||||
@@ -164,6 +168,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions()
|
||||
{
|
||||
@@ -186,16 +192,17 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
|
||||
}
|
||||
|
||||
// ----- FlushIntervalMs soft-flush coalescing (arch-review remediation WP1.2) ----- //
|
||||
// ----- FlushIntervalMs soft-flush coalescing (arch-review remediation) ----- //
|
||||
|
||||
/// <summary>
|
||||
/// A trickle of events — well under <see cref="SqliteAuditWriterOptions.BatchSize"/>,
|
||||
/// spaced closer together than <see cref="SqliteAuditWriterOptions.FlushIntervalMs"/> —
|
||||
/// 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
|
||||
/// default synchronous=FULL, its own fsync).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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
|
||||
/// not turn into an unbounded debounce.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_EventsSpacedBeyondFlushInterval_FlushSeparately()
|
||||
{
|
||||
@@ -249,6 +257,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException()
|
||||
{
|
||||
@@ -281,13 +291,15 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task WriteAsync_ForcesSidecarForwardStatePending()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending));
|
||||
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.
|
||||
var evt = NewEvent();
|
||||
await writer.WriteAsync(evt);
|
||||
@@ -297,6 +309,8 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
// ----- 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]
|
||||
public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0()
|
||||
{
|
||||
@@ -320,6 +334,9 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
[InlineData(AuditKind.CachedSubmit)]
|
||||
[InlineData(AuditKind.ApiCallCached)]
|
||||
@@ -342,6 +359,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry()
|
||||
{
|
||||
@@ -358,6 +377,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN()
|
||||
{
|
||||
@@ -387,6 +408,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded()
|
||||
{
|
||||
@@ -406,6 +429,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc()
|
||||
{
|
||||
@@ -428,6 +453,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task MarkForwardedAsync_NonExistentId_NoThrow()
|
||||
{
|
||||
@@ -440,6 +467,8 @@ public class SqliteAuditWriterWriteTests
|
||||
// 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]
|
||||
public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows()
|
||||
{
|
||||
@@ -457,8 +486,10 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN()
|
||||
{
|
||||
@@ -488,6 +519,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc()
|
||||
{
|
||||
@@ -511,6 +544,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task ReadPendingSinceAsync_ExcludesReconciledRows()
|
||||
{
|
||||
@@ -531,8 +566,10 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task ReadPendingSinceAsync_WithAfterId_SkipsRowsAtOrBeforeTheCompositeCursor()
|
||||
{
|
||||
@@ -567,6 +604,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
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
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task MarkReconciledUpToAsync_WithoutCursorId_LeavesTheBoundaryInstantServable()
|
||||
{
|
||||
@@ -626,6 +667,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task ServingRows_WithoutAnAdvancingCursor_KeepsThemServable_AtLeastOnce()
|
||||
{
|
||||
@@ -652,6 +695,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task MarkReconciledUpToAsync_LateStampedInsertBelowTheCursor_IsNeverRetired()
|
||||
{
|
||||
@@ -695,6 +740,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task MarkReconciledUpToAsync_ForwardedRowsRetire_EvenWithoutHavingBeenPulled()
|
||||
{
|
||||
@@ -722,6 +769,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task MarkReconciledUpToAsync_BeforeAnyPull_RetiresNothingPending()
|
||||
{
|
||||
@@ -745,6 +794,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws()
|
||||
{
|
||||
@@ -757,6 +808,8 @@ public class SqliteAuditWriterWriteTests
|
||||
() => 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]
|
||||
public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled()
|
||||
{
|
||||
@@ -781,6 +834,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched()
|
||||
{
|
||||
@@ -796,6 +851,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task MarkReconciledAsync_NonExistentId_NoThrow()
|
||||
{
|
||||
@@ -807,7 +864,7 @@ public class SqliteAuditWriterWriteTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// <see cref="AuditForwardState.Forwarded"/>. When a batch contains both a
|
||||
/// Pending ID and an already-Reconciled ID:
|
||||
@@ -818,6 +875,7 @@ public class SqliteAuditWriterWriteTests
|
||||
/// This mirrors the idempotency guard already present on
|
||||
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions()
|
||||
{
|
||||
@@ -879,6 +937,7 @@ public class SqliteAuditWriterWriteTests
|
||||
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> call flip atomically — and
|
||||
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> chunks the same way.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip()
|
||||
{
|
||||
@@ -912,6 +971,8 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
// ----- 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]
|
||||
public async Task WriteAsync_NonNullExecutionId_RoundTrips()
|
||||
{
|
||||
@@ -928,6 +989,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
|
||||
{
|
||||
@@ -945,6 +1008,8 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
// ----- 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]
|
||||
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
|
||||
{
|
||||
@@ -962,6 +1027,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
|
||||
{
|
||||
@@ -980,6 +1047,8 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
|
||||
{
|
||||
@@ -999,13 +1068,14 @@ public class SqliteAuditWriterWriteTests
|
||||
// ----- C4 hardening: safe enum-parse in MapRow ----- //
|
||||
|
||||
/// <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
|
||||
/// gracefully to <see cref="AuditOutcome.Success"/> (the safe
|
||||
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is
|
||||
/// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/>
|
||||
/// surface which calls the private <c>MapRow</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback()
|
||||
{
|
||||
@@ -1033,8 +1103,10 @@ public class SqliteAuditWriterWriteTests
|
||||
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]
|
||||
public async Task ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates()
|
||||
{
|
||||
|
||||
+39
-7
@@ -53,6 +53,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
SourceScript: sourceScript,
|
||||
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]
|
||||
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
|
||||
{
|
||||
@@ -78,6 +80,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered()
|
||||
{
|
||||
@@ -104,6 +108,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked()
|
||||
{
|
||||
@@ -123,6 +129,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked()
|
||||
{
|
||||
@@ -138,6 +146,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel()
|
||||
{
|
||||
@@ -157,6 +167,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task BridgeDoesNotThrow_WhenForwarderThrows()
|
||||
{
|
||||
@@ -170,6 +182,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task BridgePopulatesProvenance_FromAttemptContext()
|
||||
{
|
||||
@@ -192,12 +206,14 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
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
|
||||
// both onto the per-attempt ApiCallCached row (previously SourceScript
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
|
||||
{
|
||||
@@ -245,10 +263,12 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
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
|
||||
// rather than throwing.
|
||||
CachedCallTelemetry? captured = null;
|
||||
@@ -263,12 +283,14 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
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
|
||||
// CachedCallAttemptContext; the bridge must stamp it onto the
|
||||
// per-attempt ApiCallCached row beside ExecutionId.
|
||||
@@ -287,6 +309,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext()
|
||||
{
|
||||
@@ -312,6 +336,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task RetryLoopRow_NullParentExecutionId_RemainsNull()
|
||||
{
|
||||
@@ -329,12 +355,14 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
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
|
||||
// the SiteCallOperational.SourceNode column of every emitted packet.
|
||||
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
|
||||
@@ -353,6 +381,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull()
|
||||
{
|
||||
@@ -371,6 +401,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
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]
|
||||
public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull()
|
||||
{
|
||||
|
||||
+23
-3
@@ -111,6 +111,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
UpdatedAtUtc: _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]
|
||||
public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue()
|
||||
{
|
||||
@@ -130,7 +132,7 @@ public class CachedCallTelemetryForwarderTests
|
||||
// Tracking row: insert-if-not-exists with kind discriminator.
|
||||
// Default CreateSut() does NOT supply an INodeIdentityProvider, so the
|
||||
// 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
|
||||
// below.
|
||||
await _tracking.Received(1).RecordEnqueueAsync(
|
||||
@@ -147,6 +149,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
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]
|
||||
public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt()
|
||||
{
|
||||
@@ -170,6 +174,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
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]
|
||||
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 ─────
|
||||
// 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]
|
||||
public async Task ForwardAsync_ReturnsWhileTheAuditCommitIsStillPending()
|
||||
{
|
||||
@@ -216,6 +224,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
_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]
|
||||
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>());
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate()
|
||||
{
|
||||
@@ -257,6 +269,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
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]
|
||||
public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate()
|
||||
{
|
||||
@@ -279,6 +293,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
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]
|
||||
public async Task ForwardAsync_NullPacket_Throws()
|
||||
{
|
||||
@@ -288,12 +304,14 @@ public class CachedCallTelemetryForwarderTests
|
||||
() => 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]
|
||||
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
|
||||
// RecordEnqueueAsync sourceNode parameter so the tracking row
|
||||
// captures the originating node (node-a/node-b).
|
||||
@@ -315,6 +333,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
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]
|
||||
public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode()
|
||||
{
|
||||
|
||||
+10
-4
@@ -4,15 +4,17 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E E1 tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
|
||||
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until M6
|
||||
/// delivers the gRPC-backed implementation; both <c>IngestAuditEventsAsync</c>
|
||||
/// (M2) and <c>IngestCachedTelemetryAsync</c> (M3) must return an empty ack
|
||||
/// Tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
|
||||
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until the
|
||||
/// gRPC-backed implementation is delivered; both <c>IngestAuditEventsAsync</c>
|
||||
/// and <c>IngestCachedTelemetryAsync</c> must return an empty ack
|
||||
/// (no rows flipped to Forwarded) without throwing or partially handling the
|
||||
/// batch.
|
||||
/// </summary>
|
||||
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]
|
||||
public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck()
|
||||
{
|
||||
@@ -25,6 +27,8 @@ public class NoOpSiteStreamAuditClientTests
|
||||
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]
|
||||
public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck()
|
||||
{
|
||||
@@ -47,6 +51,8 @@ public class NoOpSiteStreamAuditClientTests
|
||||
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]
|
||||
public async Task IngestCachedTelemetryAsync_NullBatch_Throws()
|
||||
{
|
||||
|
||||
+31
-5
@@ -55,7 +55,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
(IOperationTrackingStore?)null)));
|
||||
|
||||
/// <summary>
|
||||
/// AuditLog-001: builds an actor with the optional
|
||||
/// Builds an actor with the optional
|
||||
/// <see cref="IOperationTrackingStore"/> wired in so the cached-drain
|
||||
/// scheduler is armed alongside the audit-only drain. Used by the new
|
||||
/// cached-drain regression tests below.
|
||||
@@ -86,6 +86,9 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
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]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -126,6 +129,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
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]
|
||||
public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded()
|
||||
{
|
||||
@@ -166,6 +171,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
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]
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall()
|
||||
{
|
||||
@@ -230,6 +239,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
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]
|
||||
public async Task Drain_NonZeroPending_SchedulesAtBusyInterval()
|
||||
{
|
||||
@@ -254,6 +265,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
$"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]
|
||||
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
|
||||
// routes cached rows through ReadPendingCachedTelemetryAsync +
|
||||
// IngestCachedTelemetryAsync (and NOT IngestAuditEventsAsync), and that
|
||||
@@ -305,7 +318,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Guid? eventId = null,
|
||||
Guid? correlationId = null,
|
||||
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).
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
@@ -335,12 +348,14 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
SourceScript: "script-1",
|
||||
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]
|
||||
public async Task CachedDrain_CachedRows_RouteThrough_IngestCachedTelemetry_NotIngestAuditEvents()
|
||||
{
|
||||
// Arrange — three cached audit rows on the cached queue, each with a
|
||||
// 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[]
|
||||
{
|
||||
NewCachedEvent(AuditKind.CachedSubmit),
|
||||
@@ -401,6 +416,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
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]
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
public async Task CachedDrain_OrphanRow_InsideGrace_IsRetried_NotAbandoned()
|
||||
{
|
||||
@@ -536,6 +555,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
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]
|
||||
public async Task CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue()
|
||||
{
|
||||
@@ -575,6 +596,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
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]
|
||||
public async Task CachedDrain_TrackingStoreThrow_DoesNotAbandonTheRow()
|
||||
{
|
||||
@@ -608,13 +631,15 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
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]
|
||||
public async Task AuditOnlyDrain_StillFlows_When_CachedDrain_IsDisabled()
|
||||
{
|
||||
// Arrange — ordinary (non-cached) audit rows on the audit-only queue;
|
||||
// the actor is constructed WITHOUT a tracking store so the cached
|
||||
// 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();
|
||||
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
@@ -655,6 +680,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
/// test in this class masks the bug by returning already-completed tasks
|
||||
/// from the mocks, which keeps the continuations on the actor thread.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Drains_Whose_Awaits_Complete_Off_The_Actor_Thread_Keep_Draining_Without_Crashing()
|
||||
{
|
||||
|
||||
+13
-1
@@ -61,6 +61,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
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]
|
||||
public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck()
|
||||
{
|
||||
@@ -88,6 +90,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
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]
|
||||
public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck()
|
||||
{
|
||||
@@ -110,6 +114,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
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]
|
||||
public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending()
|
||||
{
|
||||
@@ -127,6 +133,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
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]
|
||||
public async Task IngestAuditEventsAsync_FaultedReply_Throws()
|
||||
{
|
||||
@@ -136,13 +144,15 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None);
|
||||
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.
|
||||
probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted")));
|
||||
|
||||
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]
|
||||
public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply()
|
||||
{
|
||||
@@ -178,6 +188,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
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]
|
||||
public async Task IngestCachedTelemetryAsync_AskTimeout_Throws()
|
||||
{
|
||||
|
||||
@@ -11,8 +11,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||
/// </summary>
|
||||
internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider
|
||||
{
|
||||
// Returns the configured node name, which may be null.
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
{
|
||||
NodeName = nodeName;
|
||||
|
||||
@@ -30,6 +30,7 @@ internal static class StubAuditRepositoryProvider
|
||||
/// Builds a provider whose every scope yields the same audit-log repository instance.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
@@ -41,6 +41,7 @@ public class AuditConfigTableProjectionTests
|
||||
totalCount),
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
/// <summary>Verifies that projecting entries unwraps the envelope into rows.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
|
||||
{
|
||||
@@ -68,6 +69,7 @@ public class AuditConfigTableProjectionTests
|
||||
Assert.Equal("MES", second.GetProperty("entityName").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that projecting entries drops the after-state JSON.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_DropsAfterStateJson()
|
||||
{
|
||||
@@ -80,6 +82,7 @@ public class AuditConfigTableProjectionTests
|
||||
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a truncated page appends a note with the total count.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
|
||||
{
|
||||
@@ -96,6 +99,7 @@ public class AuditConfigTableProjectionTests
|
||||
Assert.False(root[2].TryGetProperty("id", out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a result set returned in full emits no note column.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
|
||||
{
|
||||
@@ -108,6 +112,7 @@ public class AuditConfigTableProjectionTests
|
||||
Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty result stays an empty array.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
|
||||
{
|
||||
@@ -124,6 +129,7 @@ public class AuditConfigTableProjectionTests
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a bare array is still projected.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_BareArray_IsStillProjected()
|
||||
{
|
||||
@@ -143,6 +149,7 @@ public class AuditConfigTableProjectionTests
|
||||
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unrecognised JSON object is returned verbatim.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
|
||||
{
|
||||
@@ -152,6 +159,7 @@ public class AuditConfigTableProjectionTests
|
||||
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-JSON input is returned verbatim.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_NonJson_ReturnedVerbatim()
|
||||
{
|
||||
@@ -159,6 +167,7 @@ public class AuditConfigTableProjectionTests
|
||||
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the projection is substantially smaller than the raw payload.</summary>
|
||||
[Fact]
|
||||
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
[Collection("Environment")]
|
||||
public class CliConfigTests
|
||||
{
|
||||
/// <summary>Verifies that loading configuration with no overrides defaults the output format to JSON.</summary>
|
||||
[Fact]
|
||||
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]
|
||||
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]
|
||||
public void Load_Format_FromEnvironment()
|
||||
{
|
||||
@@ -65,7 +68,7 @@ public class CliConfigTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// usable default config so command-line overrides (--url, --username, etc.)
|
||||
/// and env vars can still take effect.
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
[Collection("Console")]
|
||||
public class CommandHelpersTests
|
||||
{
|
||||
/// <summary>Verifies that a successful response in JSON format returns exit code zero.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_Success_JsonFormat_ReturnsZero()
|
||||
{
|
||||
@@ -21,6 +22,7 @@ public class CommandHelpersTests
|
||||
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]
|
||||
public void HandleResponse_Success_TableFormat_ArrayData_ReturnsZero()
|
||||
{
|
||||
@@ -41,6 +43,7 @@ public class CommandHelpersTests
|
||||
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]
|
||||
public void HandleResponse_Success_TableFormat_ObjectData_ReturnsZero()
|
||||
{
|
||||
@@ -61,6 +64,7 @@ public class CommandHelpersTests
|
||||
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]
|
||||
public void HandleResponse_Success_TableFormat_EmptyArray_ShowsNoResults()
|
||||
{
|
||||
@@ -76,6 +80,7 @@ public class CommandHelpersTests
|
||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an error response returns exit code one.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_Error_ReturnsOne()
|
||||
{
|
||||
@@ -91,6 +96,7 @@ public class CommandHelpersTests
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unauthorized response returns exit code two.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_Unauthorized_ReturnsTwo()
|
||||
{
|
||||
@@ -106,6 +112,7 @@ public class CommandHelpersTests
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an authentication failure response returns exit code one.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_AuthFailure_ReturnsOne()
|
||||
{
|
||||
@@ -121,6 +128,7 @@ public class CommandHelpersTests
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a connection failure response returns exit code one.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_ConnectionFailure_ReturnsOne()
|
||||
{
|
||||
@@ -136,6 +144,7 @@ public class CommandHelpersTests
|
||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a timeout response returns exit code one.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_Timeout_ReturnsOne()
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <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
|
||||
/// action, no group is empty), and that every management command record the CLI sends
|
||||
/// 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.
|
||||
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]
|
||||
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
|
||||
{
|
||||
@@ -47,6 +48,7 @@ public class CommandTreeTests
|
||||
yield return leaf;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every command group builds without throwing.</summary>
|
||||
[Fact]
|
||||
public void AllCommandGroups_Build_WithoutThrowing()
|
||||
{
|
||||
@@ -55,10 +57,11 @@ public class CommandTreeTests
|
||||
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]
|
||||
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
|
||||
// is what users actually type at the prompt.
|
||||
var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet();
|
||||
@@ -66,10 +69,11 @@ public class CommandTreeTests
|
||||
Assert.Contains("bundle", groupNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit command group exposes the query, export and verify-chain subcommands.</summary>
|
||||
[Fact]
|
||||
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.
|
||||
var audit = AuditCommands.Build(Url, Format, Username, Password);
|
||||
var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet();
|
||||
@@ -78,10 +82,11 @@ public class CommandTreeTests
|
||||
Assert.Contains("verify-chain", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the bundle command group exposes the export, preview and import subcommands.</summary>
|
||||
[Fact]
|
||||
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 subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet();
|
||||
Assert.Contains("export", subNames);
|
||||
@@ -89,6 +94,7 @@ public class CommandTreeTests
|
||||
Assert.Contains("import", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every leaf command in the tree has an action attached.</summary>
|
||||
[Fact]
|
||||
public void EveryLeafCommand_HasAnAction()
|
||||
{
|
||||
@@ -100,10 +106,11 @@ public class CommandTreeTests
|
||||
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]
|
||||
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
|
||||
// composition's own integer ID via a single --id option. Pin the real surface.
|
||||
var template = TemplateCommands.Build(Url, Format, Username, Password);
|
||||
@@ -116,6 +123,7 @@ public class CommandTreeTests
|
||||
Assert.DoesNotContain("--instance-name", optionNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the template native alarm source group has add, list and remove subcommands.</summary>
|
||||
[Fact]
|
||||
public void TemplateNativeAlarmSource_HasAddListRemove()
|
||||
{
|
||||
@@ -127,10 +135,11 @@ public class CommandTreeTests
|
||||
Assert.Contains("remove", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the deploy group has a site verb that requires a site id.</summary>
|
||||
[Fact]
|
||||
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
|
||||
// the verb names the SCOPE of the deploy.
|
||||
var deploy = DeployCommands.Build(Url, Format, Username, Password);
|
||||
@@ -143,10 +152,11 @@ public class CommandTreeTests
|
||||
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]
|
||||
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
|
||||
// site. --force is the operator escape hatch (restored volume, rebuilt
|
||||
// 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));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the instance native alarm source group has set and clear subcommands.</summary>
|
||||
[Fact]
|
||||
public void InstanceNativeAlarmSource_HasSetAndClear()
|
||||
{
|
||||
@@ -175,6 +186,7 @@ public class CommandTreeTests
|
||||
Assert.Contains("clear", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the template folder group covers every folder management command.</summary>
|
||||
[Fact]
|
||||
public void TemplateFolder_HasFullManagementCommandParity()
|
||||
{
|
||||
@@ -192,6 +204,7 @@ public class CommandTreeTests
|
||||
Assert.Contains("delete", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the parent id is optional on template folder create so the root folder is reachable.</summary>
|
||||
[Fact]
|
||||
public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable()
|
||||
{
|
||||
@@ -211,6 +224,7 @@ public class CommandTreeTests
|
||||
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]
|
||||
public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable()
|
||||
{
|
||||
@@ -224,6 +238,8 @@ public class CommandTreeTests
|
||||
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]
|
||||
[InlineData("up")]
|
||||
[InlineData("down")]
|
||||
@@ -237,6 +253,8 @@ public class CommandTreeTests
|
||||
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]
|
||||
[InlineData("sideways")]
|
||||
// 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
|
||||
// a pure client-side literal that never reaches a server-side parse, so there is no
|
||||
// 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")]
|
||||
public void TemplateFolderReorder_RejectsAnythingElse(string direction)
|
||||
{
|
||||
@@ -256,6 +276,7 @@ public class CommandTreeTests
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that template folder reorder requires a direction.</summary>
|
||||
[Fact]
|
||||
public void TemplateFolderReorder_RequiresDirection()
|
||||
{
|
||||
@@ -278,6 +299,8 @@ public class CommandTreeTests
|
||||
// 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()
|
||||
{
|
||||
// subcommand, option, value — lowercase, uppercase and mixed case of a real member.
|
||||
@@ -290,6 +313,10 @@ public class CommandTreeTests
|
||||
{ "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]
|
||||
[MemberData(nameof(AuditFilterValues))]
|
||||
public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value)
|
||||
@@ -308,6 +335,8 @@ public class CommandTreeTests
|
||||
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]
|
||||
[InlineData("csv")]
|
||||
[InlineData("CSV")]
|
||||
@@ -328,6 +357,7 @@ public class CommandTreeTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unrecognised audit query filter value is not rejected on the client side.</summary>
|
||||
[Fact]
|
||||
public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide()
|
||||
{
|
||||
@@ -342,6 +372,7 @@ public class CommandTreeTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the site area group has a move verb.</summary>
|
||||
[Fact]
|
||||
public void SiteArea_HasMoveVerb()
|
||||
{
|
||||
@@ -353,6 +384,7 @@ public class CommandTreeTests
|
||||
Assert.Contains("move", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the parent id is optional on site area move so the site root is reachable.</summary>
|
||||
[Fact]
|
||||
public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable()
|
||||
{
|
||||
@@ -372,6 +404,7 @@ public class CommandTreeTests
|
||||
Assert.Equal(2, reparentParse.GetValue((Option<int?>)parentOption));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that site area move requires an area id.</summary>
|
||||
[Fact]
|
||||
public void SiteAreaMove_RequiresId()
|
||||
{
|
||||
@@ -383,6 +416,7 @@ public class CommandTreeTests
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that template list has skip and take paging options.</summary>
|
||||
[Fact]
|
||||
public void TemplateList_HasSkipAndTakePagingOptions()
|
||||
{
|
||||
@@ -394,6 +428,7 @@ public class CommandTreeTests
|
||||
Assert.Contains("--take", optionNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that instance list has skip and take paging options.</summary>
|
||||
[Fact]
|
||||
public void InstanceList_HasSkipAndTakePagingOptions()
|
||||
{
|
||||
@@ -405,6 +440,7 @@ public class CommandTreeTests
|
||||
Assert.Contains("--take", optionNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that instance list parses the value of the take option.</summary>
|
||||
[Fact]
|
||||
public void InstanceList_ParsesTakeOptionValue()
|
||||
{
|
||||
@@ -418,6 +454,8 @@ public class CommandTreeTests
|
||||
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]
|
||||
[InlineData(typeof(GetInstanceCommand))]
|
||||
[InlineData(typeof(ListSitesCommand))]
|
||||
|
||||
@@ -8,9 +8,8 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand
|
||||
/// (Audit Log #23 M5.6 T5): argument parsing, request-body construction,
|
||||
/// HTTP wiring, and CLI scaffold.
|
||||
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand:
|
||||
/// argument parsing, request-body construction, HTTP wiring, and CLI scaffold.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditBackfillCommandTests
|
||||
@@ -19,6 +18,7 @@ public class AuditBackfillCommandTests
|
||||
// BuildRequestBody
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that building a request body from default arguments contains the expected sentinel, before and batch size fields.</summary>
|
||||
[Fact]
|
||||
public void BuildRequestBody_DefaultArgs_ContainsExpectedFields()
|
||||
{
|
||||
@@ -38,6 +38,7 @@ public class AuditBackfillCommandTests
|
||||
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]
|
||||
public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson()
|
||||
{
|
||||
@@ -66,16 +67,23 @@ public class AuditBackfillCommandTests
|
||||
private readonly HttpStatusCode _status;
|
||||
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)
|
||||
{
|
||||
_status = status;
|
||||
_responseBody = responseBody;
|
||||
}
|
||||
|
||||
/// <summary>Gets the path and query of the last request received.</summary>
|
||||
public string? LastRequestUri { get; private set; }
|
||||
/// <summary>Gets the body of the last request received.</summary>
|
||||
public string? LastRequestBody { get; private set; }
|
||||
/// <summary>Gets the HTTP method of the last request received.</summary>
|
||||
public string? LastMethod { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
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")
|
||||
=> 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]
|
||||
public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput()
|
||||
{
|
||||
@@ -117,6 +127,8 @@ public class AuditBackfillCommandTests
|
||||
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]
|
||||
public async Task RunBackfill_RequestUri_ContainsBackfillPath()
|
||||
{
|
||||
@@ -133,6 +145,8 @@ public class AuditBackfillCommandTests
|
||||
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]
|
||||
public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore()
|
||||
{
|
||||
@@ -157,6 +171,8 @@ public class AuditBackfillCommandTests
|
||||
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]
|
||||
public async Task RunBackfill_Http403_ReturnsExitCode2()
|
||||
{
|
||||
@@ -173,6 +189,8 @@ public class AuditBackfillCommandTests
|
||||
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]
|
||||
public async Task RunBackfill_Http500_ReturnsExitCode1()
|
||||
{
|
||||
@@ -193,6 +211,7 @@ public class AuditBackfillCommandTests
|
||||
// CLI parsing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the backfill-source-node subcommand exists in the audit command group.</summary>
|
||||
[Fact]
|
||||
public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup()
|
||||
{
|
||||
@@ -201,6 +220,7 @@ public class AuditBackfillCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the before option is required on the backfill-source-node subcommand.</summary>
|
||||
[Fact]
|
||||
public void BackfillSourceNode_BeforeOption_IsRequired()
|
||||
{
|
||||
@@ -209,6 +229,7 @@ public class AuditBackfillCommandTests
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the help text describes the sentinel and before options.</summary>
|
||||
[Fact]
|
||||
public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore()
|
||||
{
|
||||
@@ -223,6 +244,7 @@ public class AuditBackfillCommandTests
|
||||
Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default sentinel value is unknown.</summary>
|
||||
[Fact]
|
||||
public void BackfillSourceNode_DefaultSentinel_IsUnknown()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
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>
|
||||
/// xUnit collection so the global <see cref="Console"/> redirect is not racy.
|
||||
/// </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)
|
||||
{
|
||||
var output = new StringWriter();
|
||||
|
||||
@@ -4,9 +4,9 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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
|
||||
/// 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>
|
||||
public class AuditCommandsScaffoldTests
|
||||
{
|
||||
@@ -18,6 +18,7 @@ public class AuditCommandsScaffoldTests
|
||||
private static Command BuildAudit()
|
||||
=> AuditCommands.Build(Url, Format, Username, Password);
|
||||
|
||||
/// <summary>Verifies that the command group is named <c>audit</c>.</summary>
|
||||
[Fact]
|
||||
public void Audit_Command_IsNamedAudit()
|
||||
{
|
||||
@@ -26,6 +27,7 @@ public class AuditCommandsScaffoldTests
|
||||
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]
|
||||
public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode()
|
||||
{
|
||||
@@ -36,6 +38,7 @@ public class AuditCommandsScaffoldTests
|
||||
names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit help text lists every subcommand.</summary>
|
||||
[Fact]
|
||||
public void Audit_HelpText_ListsAllSubcommands()
|
||||
{
|
||||
@@ -55,6 +58,7 @@ public class AuditCommandsScaffoldTests
|
||||
Assert.Contains("backfill-source-node", text);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every leaf command in the audit group has an action wired.</summary>
|
||||
[Fact]
|
||||
public void Audit_EveryLeafCommand_HasAnAction()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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
|
||||
/// 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.
|
||||
@@ -28,6 +28,7 @@ public class AuditConfigDeprecationTests
|
||||
return root;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit-config query command works.</summary>
|
||||
[Fact]
|
||||
public void AuditConfig_Query_Works()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public class AuditConfigDeprecationTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the legacy audit-log query command still works but emits a deprecation warning to stderr.</summary>
|
||||
[Fact]
|
||||
public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr()
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public class AuditConfigDeprecationTests
|
||||
Assert.Contains("audit-config", warning);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no deprecation warning is emitted when the new command name is used.</summary>
|
||||
[Fact]
|
||||
public void DeprecationWarning_NotEmitted_ForNewName()
|
||||
{
|
||||
@@ -64,6 +67,7 @@ public class AuditConfigDeprecationTests
|
||||
Assert.Equal("", stderr.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no deprecation warning is emitted for an unrelated command.</summary>
|
||||
[Fact]
|
||||
public void DeprecationWarning_NotEmitted_ForUnrelatedCommand()
|
||||
{
|
||||
@@ -73,6 +77,7 @@ public class AuditConfigDeprecationTests
|
||||
Assert.Equal("", stderr.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit and audit-config groups are distinct commands that do not conflict.</summary>
|
||||
[Fact]
|
||||
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;
|
||||
|
||||
/// <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
|
||||
/// to the output file, and the parquet-not-implemented (501) path.
|
||||
/// </summary>
|
||||
@@ -17,6 +17,7 @@ public class AuditExportCommandTests
|
||||
{
|
||||
// ---- CLI parsing: required flags --------------------------------------
|
||||
|
||||
/// <summary>Verifies that omitting a required flag produces a parse error and a non-zero exit code.</summary>
|
||||
[Fact]
|
||||
public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public class AuditExportCommandTests
|
||||
Assert.Contains("--output", err);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the command parses without error when all required flags are present.</summary>
|
||||
[Fact]
|
||||
public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public class AuditExportCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid format value is not rejected at the parse layer.</summary>
|
||||
[Fact]
|
||||
public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer()
|
||||
{
|
||||
@@ -56,6 +59,8 @@ public class AuditExportCommandTests
|
||||
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]
|
||||
[InlineData("CSV")]
|
||||
[InlineData("Csv")]
|
||||
@@ -73,6 +78,7 @@ public class AuditExportCommandTests
|
||||
|
||||
// ---- Query string -----------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the query string includes the time window, the format and the filters.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_IncludesWindowFormatAndFilters()
|
||||
{
|
||||
@@ -96,6 +102,7 @@ public class AuditExportCommandTests
|
||||
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]
|
||||
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
|
||||
{
|
||||
@@ -120,6 +127,7 @@ public class AuditExportCommandTests
|
||||
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]
|
||||
public void BuildQueryString_OmitsUnsetMultiValueFilters()
|
||||
{
|
||||
@@ -140,6 +148,7 @@ public class AuditExportCommandTests
|
||||
Assert.Null(parsed["sourceSiteId"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple channel values supplied in a single token are accepted.</summary>
|
||||
[Fact]
|
||||
public void Export_MultipleChannelValues_SingleToken_AreAccepted()
|
||||
{
|
||||
@@ -154,6 +163,7 @@ public class AuditExportCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple channel values supplied through a repeated flag are accepted.</summary>
|
||||
[Fact]
|
||||
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
||||
{
|
||||
@@ -167,6 +177,7 @@ public class AuditExportCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a multi-value channel option still parses when one name is unrecognised.</summary>
|
||||
[Fact]
|
||||
public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
||||
{
|
||||
@@ -188,14 +199,19 @@ public class AuditExportCommandTests
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
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; }
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_status = status;
|
||||
_content = content;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
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]
|
||||
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]
|
||||
public async Task RunExport_Http403_ReturnsExitCode2()
|
||||
{
|
||||
// CLI-018: 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
|
||||
// An HTTP 403 on /api/audit/export must produce exit code 2 per the
|
||||
// documented CLI contract — the legacy plain exit code 1 masked auth failures
|
||||
// as generic command failures.
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
|
||||
{
|
||||
@@ -351,18 +377,27 @@ public class AuditExportCommandTests
|
||||
private readonly byte _value;
|
||||
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)
|
||||
{
|
||||
_value = value;
|
||||
_remaining = length;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => true;
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_remaining <= 0) return 0;
|
||||
@@ -372,14 +407,18 @@ public class AuditExportCommandTests
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() { }
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
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
|
||||
// forever). Every test below bounds its own wait well under what the old,
|
||||
// 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>
|
||||
private sealed class HangingHandler : HttpMessageHandler
|
||||
{
|
||||
/// <summary>Gets the number of requests the handler has received.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -410,17 +451,26 @@ public class AuditExportCommandTests
|
||||
private readonly byte[] _firstChunk;
|
||||
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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => true;
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_servedFirstChunk)
|
||||
@@ -435,9 +485,13 @@ public class AuditExportCommandTests
|
||||
return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() { }
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
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 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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => true;
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_servedFirstChunk)
|
||||
@@ -471,9 +534,13 @@ public class AuditExportCommandTests
|
||||
throw new IOException("connection reset by peer (simulated)");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() { }
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -494,6 +561,8 @@ public class AuditExportCommandTests
|
||||
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]
|
||||
public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError()
|
||||
{
|
||||
@@ -521,6 +590,8 @@ public class AuditExportCommandTests
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
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;
|
||||
|
||||
/// <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
|
||||
/// handling, and keyset-cursor paging via <c>--all</c>.
|
||||
/// </summary>
|
||||
@@ -17,6 +17,7 @@ public class AuditQueryCommandTests
|
||||
{
|
||||
// ---- Time-spec parsing -------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a relative hours time spec resolves to the current time minus that offset.</summary>
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset()
|
||||
{
|
||||
@@ -25,6 +26,7 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset()
|
||||
{
|
||||
@@ -33,6 +35,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an absolute ISO 8601 time spec is parsed verbatim.</summary>
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim()
|
||||
{
|
||||
@@ -41,6 +44,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unparseable time spec throws.</summary>
|
||||
[Fact]
|
||||
public void ResolveTimeSpec_Garbage_Throws()
|
||||
{
|
||||
@@ -50,6 +54,7 @@ public class AuditQueryCommandTests
|
||||
|
||||
// ---- Query string construction ----------------------------------------
|
||||
|
||||
/// <summary>Verifies that a fully populated flag set produces the expected query-string parameters.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_FullFlagSet_ProducesExpectedParameters()
|
||||
{
|
||||
@@ -90,6 +95,7 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
public void BuildQueryString_ErrorsOnly_MapsToFailedStatus()
|
||||
{
|
||||
@@ -100,6 +106,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal("Failed", parsed["status"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a multi-value channel filter emits one query key per value.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
|
||||
{
|
||||
@@ -119,6 +126,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the errors-only flag overrides any explicit status values.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
|
||||
{
|
||||
@@ -137,6 +145,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the paging cursor is appended after the filter parameters.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_Cursor_AppendsAfterParameters()
|
||||
{
|
||||
@@ -149,6 +158,7 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
public void BuildQueryString_OmitsUnsetFilters()
|
||||
{
|
||||
@@ -165,6 +175,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Equal("100", parsed["pageSize"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an execution id filter emits the execution id parameter.</summary>
|
||||
[Fact]
|
||||
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
|
||||
{
|
||||
@@ -176,6 +187,7 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
|
||||
{
|
||||
@@ -192,13 +204,17 @@ public class AuditQueryCommandTests
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<string> _bodies;
|
||||
/// <summary>The request URIs received by the handler, in order.</summary>
|
||||
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)
|
||||
{
|
||||
_bodies = new Queue<string>(bodies);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
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]
|
||||
public async Task RunQuery_SinglePage_WritesEventsAsJsonLines()
|
||||
{
|
||||
@@ -231,6 +249,8 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages()
|
||||
{
|
||||
@@ -251,6 +271,8 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent()
|
||||
{
|
||||
@@ -267,6 +289,8 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
public async Task RunQuery_ServerError_ReturnsNonZeroExit()
|
||||
{
|
||||
@@ -281,12 +305,14 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
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
|
||||
// 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 client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
@@ -298,11 +324,13 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
||||
{
|
||||
// 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 client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
@@ -314,10 +342,12 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
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.
|
||||
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
@@ -332,6 +362,7 @@ public class AuditQueryCommandTests
|
||||
|
||||
private sealed class ErrorHandler : HttpMessageHandler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
@@ -344,7 +375,11 @@ public class AuditQueryCommandTests
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
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; }
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||
@@ -352,6 +387,7 @@ public class AuditQueryCommandTests
|
||||
|
||||
// ---- CLI parsing -------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that an unknown flag produces a parse error and a non-zero exit code.</summary>
|
||||
[Fact]
|
||||
public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit()
|
||||
{
|
||||
@@ -361,6 +397,7 @@ public class AuditQueryCommandTests
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the table output format is accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_FormatTable_IsAccepted()
|
||||
{
|
||||
@@ -369,6 +406,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the execution id option is accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_ExecutionIdOption_IsAccepted()
|
||||
{
|
||||
@@ -381,6 +419,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the parent execution id option is accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_ParentExecutionIdOption_IsAccepted()
|
||||
{
|
||||
@@ -395,6 +434,7 @@ public class AuditQueryCommandTests
|
||||
|
||||
// ---- Enum-name validation (fast-fail) ----------------------------------
|
||||
|
||||
/// <summary>Verifies that a channel option using a real enum name is accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_ChannelWithRealEnumName_IsAccepted()
|
||||
{
|
||||
@@ -403,6 +443,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple channel values in a single token are accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
|
||||
{
|
||||
@@ -412,6 +453,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple channel values given by repeating the flag are accepted.</summary>
|
||||
[Fact]
|
||||
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
||||
{
|
||||
@@ -438,6 +480,9 @@ public class AuditQueryCommandTests
|
||||
// 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]
|
||||
[InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound"
|
||||
[InlineData("--kind", "CachedCall")]
|
||||
@@ -449,6 +494,7 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a multi-value channel filter containing one unrecognised name still parses.</summary>
|
||||
[Fact]
|
||||
public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
||||
{
|
||||
@@ -459,6 +505,9 @@ public class AuditQueryCommandTests
|
||||
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]
|
||||
[InlineData("--channel", "apioutbound")]
|
||||
[InlineData("--kind", "apicall")]
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class AuditTableFormatterTests
|
||||
@@ -18,6 +18,7 @@ public class AuditTableFormatterTests
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the table renders a header row with the expected columns.</summary>
|
||||
[Fact]
|
||||
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]
|
||||
public void Table_TruncatesLongTarget_WithEllipsis()
|
||||
{
|
||||
@@ -57,6 +59,7 @@ public class AuditTableFormatterTests
|
||||
Assert.DoesNotContain(longTarget, text);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty result set renders only the header or a no-rows message.</summary>
|
||||
[Fact]
|
||||
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
|
||||
{
|
||||
@@ -73,6 +76,7 @@ public class AuditTableFormatterTests
|
||||
Assert.Contains("OccurredAtUtc", lines[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null actor renders as a blank cell.</summary>
|
||||
[Fact]
|
||||
public void Table_NullActor_RendersBlank()
|
||||
{
|
||||
@@ -93,6 +97,7 @@ public class AuditTableFormatterTests
|
||||
Assert.Contains("InboundApi", lines[1]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the header is emitted once per page and the data rows are aligned.</summary>
|
||||
[Fact]
|
||||
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
@@ -58,6 +58,7 @@ public class AuditTreeCommandTests
|
||||
// ParseNodes
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that parsing a valid JSON array returns the matching node DTOs.</summary>
|
||||
[Fact]
|
||||
public void ParseNodes_ValidArray_ReturnsDtos()
|
||||
{
|
||||
@@ -75,6 +76,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Equal(3, nodes[0].RowCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing an empty JSON array returns an empty list.</summary>
|
||||
[Fact]
|
||||
public void ParseNodes_EmptyArray_ReturnsEmpty()
|
||||
{
|
||||
@@ -82,6 +84,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Empty(nodes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing invalid JSON returns an empty list.</summary>
|
||||
[Fact]
|
||||
public void ParseNodes_InvalidJson_ReturnsEmpty()
|
||||
{
|
||||
@@ -93,6 +96,7 @@ public class AuditTreeCommandTests
|
||||
// WriteTable — ASCII tree rendering
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that writing a table for no nodes prints the fallback message.</summary>
|
||||
[Fact]
|
||||
public void WriteTable_EmptyNodes_PrintsFallbackMessage()
|
||||
{
|
||||
@@ -101,6 +105,7 @@ public class AuditTreeCommandTests
|
||||
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]
|
||||
public void WriteTable_SingleRootNode_PrintsWithNoIndent()
|
||||
{
|
||||
@@ -117,6 +122,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Contains("[*]", line); // queried node marked
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing a table for a multi-level tree indents each child level correctly.</summary>
|
||||
[Fact]
|
||||
public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly()
|
||||
{
|
||||
@@ -144,6 +150,7 @@ public class AuditTreeCommandTests
|
||||
Assert.StartsWith(" ", grandLine);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing a table marks the queried node and leaves the others unmarked.</summary>
|
||||
[Fact]
|
||||
public void WriteTable_QueriedNodeIsMarked_OthersAreNot()
|
||||
{
|
||||
@@ -167,6 +174,7 @@ public class AuditTreeCommandTests
|
||||
// WriteJson
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that writing JSON for valid nodes emits a valid JSON array.</summary>
|
||||
[Fact]
|
||||
public void WriteJson_ValidNodes_EmitsValidJsonArray()
|
||||
{
|
||||
@@ -183,6 +191,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing JSON for no nodes emits an empty array.</summary>
|
||||
[Fact]
|
||||
public void WriteJson_EmptyNodes_EmitsEmptyArray()
|
||||
{
|
||||
@@ -204,14 +213,19 @@ public class AuditTreeCommandTests
|
||||
private readonly HttpStatusCode _status;
|
||||
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)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last request uri value.</summary>
|
||||
public string? LastRequestUri { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
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]
|
||||
public async Task RunTree_Success_ReturnsZeroAndWritesOutput()
|
||||
{
|
||||
@@ -239,6 +255,8 @@ public class AuditTreeCommandTests
|
||||
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]
|
||||
public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage()
|
||||
{
|
||||
@@ -253,6 +271,8 @@ public class AuditTreeCommandTests
|
||||
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]
|
||||
public async Task RunTree_JsonFormat_EmitsValidJson()
|
||||
{
|
||||
@@ -269,6 +289,8 @@ public class AuditTreeCommandTests
|
||||
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]
|
||||
public async Task RunTree_Http403_ReturnsExitCode2()
|
||||
{
|
||||
@@ -282,6 +304,8 @@ public class AuditTreeCommandTests
|
||||
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]
|
||||
public async Task RunTree_Http500_ReturnsExitCode1()
|
||||
{
|
||||
@@ -295,6 +319,8 @@ public class AuditTreeCommandTests
|
||||
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]
|
||||
public async Task RunTree_RequestUrlContainsExecutionId()
|
||||
{
|
||||
@@ -313,6 +339,7 @@ public class AuditTreeCommandTests
|
||||
// CLI parsing — audit tree subcommand
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the tree subcommand exists in the audit command group.</summary>
|
||||
[Fact]
|
||||
public void Tree_Subcommand_ExistsInAuditCommandGroup()
|
||||
{
|
||||
@@ -322,6 +349,7 @@ public class AuditTreeCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree execution id option is required.</summary>
|
||||
[Fact]
|
||||
public void Tree_ExecutionIdOption_IsRequired()
|
||||
{
|
||||
@@ -332,6 +360,7 @@ public class AuditTreeCommandTests
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tree help text describes the execution id.</summary>
|
||||
[Fact]
|
||||
public void Tree_HelpText_DescribesExecutionId()
|
||||
{
|
||||
|
||||
@@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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"
|
||||
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditVerifyChainCommandTests
|
||||
{
|
||||
/// <summary>Verifies that verifying the chain for a valid month exits with zero and prints the documented message.</summary>
|
||||
[Fact]
|
||||
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public class AuditVerifyChainCommandTests
|
||||
Assert.Contains("Component-AuditLog.md", output);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a malformed month value makes the command exit with a non-zero code.</summary>
|
||||
[Fact]
|
||||
public void VerifyChain_MalformedMonth_ExitsNonZero()
|
||||
{
|
||||
@@ -32,6 +34,7 @@ public class AuditVerifyChainCommandTests
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting the month option produces the required-option error.</summary>
|
||||
[Fact]
|
||||
public void VerifyChain_MissingMonth_ProducesRequiredFlagError()
|
||||
{
|
||||
@@ -42,6 +45,9 @@ public class AuditVerifyChainCommandTests
|
||||
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]
|
||||
[InlineData("2026-05", true)]
|
||||
[InlineData("2026-01", true)]
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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>,
|
||||
/// 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
|
||||
@@ -15,11 +15,13 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath;
|
||||
|
||||
/// <summary>Creates a unique temporary output path for each test.</summary>
|
||||
public BundleCommandsStreamingTests()
|
||||
{
|
||||
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources used by this instance.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
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]
|
||||
public void StreamBase64ToFile_SmallPayload_RoundTrips()
|
||||
{
|
||||
@@ -41,6 +44,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal(bytes, roundTripped);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a payload spanning several chunks, with a short final chunk, round-trips unchanged.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
|
||||
{
|
||||
@@ -60,6 +64,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal(bytes, roundTripped);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty base64 string produces an empty file.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
|
||||
{
|
||||
@@ -70,6 +75,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Empty(File.ReadAllBytes(_tempPath));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid base64 text throws a <see cref="FormatException"/>.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
|
||||
{
|
||||
@@ -81,20 +87,23 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null base64 string throws an <see cref="ArgumentNullException"/>.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_NullBase64_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty output path throws an <see cref="ArgumentException"/>.</summary>
|
||||
[Fact]
|
||||
public void StreamBase64ToFile_EmptyOutputPath_Throws()
|
||||
{
|
||||
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]
|
||||
public void ParseNameList_CommaSeparated_SplitsAndTrims()
|
||||
{
|
||||
@@ -103,6 +112,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
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]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -112,6 +123,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Null(BundleCommands.ParseNameList(token));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty and whitespace-only entries are dropped from the list.</summary>
|
||||
[Fact]
|
||||
public void ParseNameList_EmptyEntries_AreDropped()
|
||||
{
|
||||
@@ -119,6 +131,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Equal(new[] { "a", "b", "c" }, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the export command accepts the sites and instances flags without parse errors.</summary>
|
||||
[Fact]
|
||||
public void BundleExport_SitesAndInstancesFlags_ParseWithoutError()
|
||||
{
|
||||
@@ -138,14 +151,16 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
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]
|
||||
public void ParseSiteMappings_Null_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(BundleCommands.ParseSiteMappings(null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a source=target spec maps the source site to an existing target site.</summary>
|
||||
[Fact]
|
||||
public void ParseSiteMappings_SrcEqualsDst_MapsToExisting()
|
||||
{
|
||||
@@ -155,6 +170,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
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]
|
||||
[InlineData("NORTH-01")] // no '=' part -> create-new
|
||||
[InlineData("NORTH-01=")] // empty rhs -> create-new
|
||||
@@ -167,6 +184,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Null(spec.TargetSiteIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that repeated mapping specs accumulate into one list.</summary>
|
||||
[Fact]
|
||||
public void ParseSiteMappings_Repeated_AccumulatesAll()
|
||||
{
|
||||
@@ -176,12 +194,14 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
Assert.Null(specs[1].TargetSiteIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a spec with an empty source throws a <see cref="FormatException"/>.</summary>
|
||||
[Fact]
|
||||
public void ParseSiteMappings_EmptySource_Throws()
|
||||
{
|
||||
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]
|
||||
public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting()
|
||||
{
|
||||
@@ -192,6 +212,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
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]
|
||||
[InlineData("NORTH-01/OpcA")] // no '=' part -> create-new
|
||||
[InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new
|
||||
@@ -204,6 +226,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
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]
|
||||
[InlineData("OpcA")] // missing site/name shape
|
||||
[InlineData("NORTH-01/")] // empty connection name
|
||||
@@ -213,6 +237,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
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]
|
||||
public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ public class CachedCallCommandsTests
|
||||
|
||||
// ── retry ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the retry command with a site id and message id produces the expected management command.</summary>
|
||||
[Fact]
|
||||
public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public class CachedCallCommandsTests
|
||||
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]
|
||||
public void Retry_SiteIdAndTrackedOperationId_AreRequired()
|
||||
{
|
||||
@@ -50,6 +52,7 @@ public class CachedCallCommandsTests
|
||||
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]
|
||||
public void Retry_MissingOptions_ProducesParseErrors()
|
||||
{
|
||||
@@ -63,6 +66,7 @@ public class CachedCallCommandsTests
|
||||
|
||||
// ── discard ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the discard command with a site id and message id produces the expected management command.</summary>
|
||||
[Fact]
|
||||
public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
||||
{
|
||||
@@ -79,6 +83,7 @@ public class CachedCallCommandsTests
|
||||
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]
|
||||
public void Discard_SiteIdAndTrackedOperationId_AreRequired()
|
||||
{
|
||||
@@ -92,6 +97,7 @@ public class CachedCallCommandsTests
|
||||
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]
|
||||
public void Discard_MissingOptions_ProducesParseErrors()
|
||||
{
|
||||
@@ -105,6 +111,7 @@ public class CachedCallCommandsTests
|
||||
|
||||
// ── group shape ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the cached-call command group contains the retry and discard subcommands.</summary>
|
||||
[Fact]
|
||||
public void CachedCallGroup_HasRetryAndDiscard()
|
||||
{
|
||||
@@ -115,6 +122,7 @@ public class CachedCallCommandsTests
|
||||
Assert.Contains("discard", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the cached-call command group is named cached-call.</summary>
|
||||
[Fact]
|
||||
public void CachedCallGroup_Name_IsCachedCall()
|
||||
{
|
||||
@@ -124,6 +132,7 @@ public class CachedCallCommandsTests
|
||||
|
||||
// ── registry round-trip ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that the retry-parked-message command resolves through the command registry.</summary>
|
||||
[Fact]
|
||||
public void RetryParkedMessageCommand_ResolvesViaRegistry()
|
||||
{
|
||||
@@ -132,6 +141,7 @@ public class CachedCallCommandsTests
|
||||
Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the discard-parked-message command resolves through the command registry.</summary>
|
||||
[Fact]
|
||||
public void DiscardParkedMessageCommand_ResolvesViaRegistry()
|
||||
{
|
||||
|
||||
@@ -26,6 +26,8 @@ public class ExternalSystemRetryOptionTests
|
||||
private static Option? FindOption(Command command, string 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]
|
||||
[InlineData("create")]
|
||||
[InlineData("update")]
|
||||
@@ -42,6 +44,7 @@ public class ExternalSystemRetryOptionTests
|
||||
Assert.False(retryDelay!.Required);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the create subcommand parses the supplied retry-policy values.</summary>
|
||||
[Fact]
|
||||
public void Create_ParsesRetryPolicyValues()
|
||||
{
|
||||
@@ -56,6 +59,7 @@ public class ExternalSystemRetryOptionTests
|
||||
Assert.Equal(60, parse.GetValue<int?>("--retry-delay-seconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the update subcommand parses the supplied retry-policy values.</summary>
|
||||
[Fact]
|
||||
public void Update_ParsesRetryPolicyValues()
|
||||
{
|
||||
@@ -70,6 +74,7 @@ public class ExternalSystemRetryOptionTests
|
||||
Assert.Equal(10, parse.GetValue<int?>("--retry-delay-seconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the retry-delay option alias is accepted by the parser.</summary>
|
||||
[Fact]
|
||||
public void RetryDelayAliasIsAccepted()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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
|
||||
/// carries the per-system ESG HTTP timeout. Omitting it preserves the existing value
|
||||
/// (update) / uses the gateway default (create).
|
||||
@@ -21,6 +21,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
private static Option? FindOption(Command command, string name)
|
||||
=> command.Options.SingleOrDefault(o => o.Name == name);
|
||||
|
||||
/// <summary>Verifies that the create command exposes an optional timeout-seconds option.</summary>
|
||||
[Fact]
|
||||
public void Create_HasOptionalTimeoutSecondsOption()
|
||||
{
|
||||
@@ -30,6 +31,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
Assert.False(option!.Required);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the update command exposes an optional timeout-seconds option.</summary>
|
||||
[Fact]
|
||||
public void Update_HasOptionalTimeoutSecondsOption()
|
||||
{
|
||||
@@ -39,6 +41,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
Assert.False(option!.Required);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the create command parses a supplied timeout-seconds value.</summary>
|
||||
[Fact]
|
||||
public void Create_ParsesTimeoutSecondsValue()
|
||||
{
|
||||
@@ -52,6 +55,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
Assert.Equal(15, parse.GetValue<int?>("--timeout-seconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the update command parses a supplied timeout-seconds value.</summary>
|
||||
[Fact]
|
||||
public void Update_ParsesTimeoutSecondsValue()
|
||||
{
|
||||
@@ -65,6 +69,7 @@ public class ExternalSystemTimeoutOptionTests
|
||||
Assert.Equal(20, parse.GetValue<int?>("--timeout-seconds"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting timeout-seconds on create parses without error.</summary>
|
||||
[Fact]
|
||||
public void Create_OmittedTimeoutSeconds_ParsesWithoutError()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@ public class NotificationSmsCommandTests
|
||||
|
||||
// ---- list create/update: --type + --phones ----------------------------
|
||||
|
||||
/// <summary>Verifies that creating an SMS list with phones builds an SMS command carrying the recipient phones.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
||||
{
|
||||
@@ -55,6 +56,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Empty(cmd.RecipientEmails);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the list type option is matched case-insensitively.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_TypeIsCaseInsensitive()
|
||||
{
|
||||
@@ -66,6 +68,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Equal(NotificationType.Sms, cmd.Type);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list created without a type defaults to email.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_DefaultType_IsEmail()
|
||||
{
|
||||
@@ -79,6 +82,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Null(cmd.RecipientPhones);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an email list given phone numbers is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_EmailTypeWithPhones_Throws()
|
||||
{
|
||||
@@ -90,6 +94,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("--phones", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS list with no phone numbers is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithoutPhones_Throws()
|
||||
{
|
||||
@@ -100,6 +105,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("--phones", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an email list with no email addresses is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_EmailTypeWithoutEmails_Throws()
|
||||
{
|
||||
@@ -110,6 +116,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("--emails", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS list given email addresses is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithEmails_Throws()
|
||||
{
|
||||
@@ -121,6 +128,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("--emails", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown list type is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListCreate_UnknownType_Throws()
|
||||
{
|
||||
@@ -131,6 +139,7 @@ public class NotificationSmsCommandTests
|
||||
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating an SMS list with phones builds an SMS command carrying the recipient phones.</summary>
|
||||
[Fact]
|
||||
public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
||||
{
|
||||
@@ -146,6 +155,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Empty(cmd.RecipientEmails);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that updating an email list with phone numbers is rejected.</summary>
|
||||
[Fact]
|
||||
public void ListUpdate_EmailTypeWithPhones_Throws()
|
||||
{
|
||||
@@ -160,6 +170,7 @@ public class NotificationSmsCommandTests
|
||||
|
||||
// ---- notification sms group -------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the notification command has an SMS group with list and update subcommands.</summary>
|
||||
[Fact]
|
||||
public void Notification_HasSmsGroupWithListAndUpdate()
|
||||
{
|
||||
@@ -168,6 +179,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Contains("update", subNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS update with every flag produces a command carrying all of them.</summary>
|
||||
[Fact]
|
||||
public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem()
|
||||
{
|
||||
@@ -187,6 +199,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Equal("new-secret", cmd.AuthToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting the optional flags produces a command with null values for them.</summary>
|
||||
[Fact]
|
||||
public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls()
|
||||
{
|
||||
@@ -204,6 +217,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Null(cmd.AuthToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the optional SMS update flags are not marked as required.</summary>
|
||||
[Fact]
|
||||
public void SmsUpdate_OptionalFlags_AreNotRequired()
|
||||
{
|
||||
@@ -214,6 +228,7 @@ public class NotificationSmsCommandTests
|
||||
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]
|
||||
public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds()
|
||||
{
|
||||
@@ -229,6 +244,7 @@ public class NotificationSmsCommandTests
|
||||
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]
|
||||
public void SmsUpdate_NeitherFromNorMessagingService_Throws()
|
||||
{
|
||||
@@ -239,6 +255,7 @@ public class NotificationSmsCommandTests
|
||||
Assert.Throws<ArgumentException>(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that both SMS commands resolve through the management command registry.</summary>
|
||||
[Fact]
|
||||
public void SmsCommands_ResolveViaRegistry()
|
||||
{
|
||||
@@ -262,6 +279,7 @@ public class NotificationSmsCommandTests
|
||||
[Collection("Console")]
|
||||
public class NotificationSmsAuthTokenRenderingTests
|
||||
{
|
||||
/// <summary>Verifies that a rendered SMS configuration list never contains the auth token value.</summary>
|
||||
[Fact]
|
||||
public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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>
|
||||
/// filters, and map them onto <see cref="Commons.Messages.Management.ListSecuredWritesCommand"/>.
|
||||
/// 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)
|
||||
=> command.Options.SingleOrDefault(o => o.Name == name);
|
||||
|
||||
/// <summary>Verifies that the list command exposes the skip and take options.</summary>
|
||||
[Fact]
|
||||
public void List_HasSkipAndTakeOptions()
|
||||
{
|
||||
@@ -31,6 +32,7 @@ public class SecuredWriteListCommandTests
|
||||
Assert.NotNull(FindOption(list, "--take"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the list command parses skip, take and filters and forwards them to the command.</summary>
|
||||
[Fact]
|
||||
public void List_ParsesSkipTakeAndFilters_ForwardsToCommand()
|
||||
{
|
||||
@@ -47,6 +49,7 @@ public class SecuredWriteListCommandTests
|
||||
Assert.Equal(25, command.Take);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting the paging options uses the unpaged defaults.</summary>
|
||||
[Fact]
|
||||
public void List_OmittedPaging_UsesUnpagedDefaults()
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ public class SmtpUpdateCommandTests
|
||||
private static ParseResult ParseUpdate(params string[] args)
|
||||
=> SmtpUpdateCommand().Parse(args);
|
||||
|
||||
/// <summary>Verifies that an update with a TLS mode and credentials produces a command carrying both.</summary>
|
||||
[Fact]
|
||||
public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem()
|
||||
{
|
||||
@@ -49,6 +50,7 @@ public class SmtpUpdateCommandTests
|
||||
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]
|
||||
public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls()
|
||||
{
|
||||
@@ -64,6 +66,8 @@ public class SmtpUpdateCommandTests
|
||||
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]
|
||||
[InlineData("None")]
|
||||
[InlineData("StartTLS")]
|
||||
@@ -78,6 +82,8 @@ public class SmtpUpdateCommandTests
|
||||
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]
|
||||
[InlineData("Bogus")]
|
||||
[InlineData("tls")]
|
||||
@@ -92,6 +98,7 @@ public class SmtpUpdateCommandTests
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an update with the EWS transport produces a command carrying that transport.</summary>
|
||||
[Fact]
|
||||
public void Update_WithTransportEws_ProducesCommandCarryingIt()
|
||||
{
|
||||
@@ -106,6 +113,7 @@ public class SmtpUpdateCommandTests
|
||||
Assert.Equal("Ews", cmd.Transport);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an update without a transport produces a command with a null transport.</summary>
|
||||
[Fact]
|
||||
public void Update_WithoutTransport_ProducesCommandWithNullTransport()
|
||||
{
|
||||
@@ -119,6 +127,8 @@ public class SmtpUpdateCommandTests
|
||||
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]
|
||||
[InlineData("Graph")]
|
||||
[InlineData("ews")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
|
||||
@@ -132,6 +142,7 @@ public class SmtpUpdateCommandTests
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the TLS mode and credentials options are not required.</summary>
|
||||
[Fact]
|
||||
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;
|
||||
|
||||
/// <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,
|
||||
/// a JSON-array <c>--value</c>, client-side element-type validation, and
|
||||
/// <see cref="AddTemplateAttributeCommand.ElementDataType"/> /
|
||||
@@ -25,6 +25,7 @@ public class TemplateAttributeListTests
|
||||
|
||||
// ---- option surface ----
|
||||
|
||||
/// <summary>Verifies that the attribute add command exposes an element-type option.</summary>
|
||||
[Fact]
|
||||
public void AttributeAdd_HasElementTypeOption()
|
||||
{
|
||||
@@ -32,6 +33,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Contains("--element-type", add.Options.Select(o => o.Name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the attribute update command exposes an element-type option.</summary>
|
||||
[Fact]
|
||||
public void AttributeUpdate_HasElementTypeOption()
|
||||
{
|
||||
@@ -41,6 +43,8 @@ public class TemplateAttributeListTests
|
||||
|
||||
// ---- 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]
|
||||
[InlineData("String")]
|
||||
[InlineData("Int32")]
|
||||
@@ -55,6 +59,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that element-type validation for a List attribute is case-insensitive.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithValidScalar_CaseInsensitive()
|
||||
{
|
||||
@@ -63,6 +68,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a List attribute without an element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithoutElementType_Error()
|
||||
{
|
||||
@@ -71,6 +77,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a List attribute with a blank element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithBlankElementType_Error()
|
||||
{
|
||||
@@ -79,6 +86,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a List attribute with an invalid scalar element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithInvalidScalar_Error()
|
||||
{
|
||||
@@ -87,6 +95,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a List attribute with a binary element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithBinaryScalar_Error()
|
||||
{
|
||||
@@ -96,6 +105,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar attribute with an element type fails validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ScalarWithElementType_Error()
|
||||
{
|
||||
@@ -104,6 +114,7 @@ public class TemplateAttributeListTests
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a scalar attribute without an element type passes validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateElementType_ScalarWithoutElementType_Ok()
|
||||
{
|
||||
@@ -114,6 +125,7 @@ public class TemplateAttributeListTests
|
||||
|
||||
// ---- 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]
|
||||
public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
||||
{
|
||||
@@ -134,6 +146,7 @@ public class TemplateAttributeListTests
|
||||
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]
|
||||
public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
||||
{
|
||||
@@ -153,6 +166,7 @@ public class TemplateAttributeListTests
|
||||
Assert.Equal("""["A","B"]""", cmd.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that building an add command for a scalar attribute leaves the element type null.</summary>
|
||||
[Fact]
|
||||
public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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)
|
||||
/// so the per-script throttle/re-fire interval and the execution-timeout override —
|
||||
/// previously settable only via Transport bundle import — are CLI-authorable. These
|
||||
@@ -24,6 +24,7 @@ public class TemplateScriptTimingTests
|
||||
|
||||
// ---- option surface ----
|
||||
|
||||
/// <summary>Verifies that <c>template script add</c> exposes the timing options.</summary>
|
||||
[Fact]
|
||||
public void ScriptAdd_HasTimingOptions()
|
||||
{
|
||||
@@ -33,6 +34,7 @@ public class TemplateScriptTimingTests
|
||||
Assert.Contains("--execution-timeout-seconds", names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <c>template script update</c> exposes the timing options.</summary>
|
||||
[Fact]
|
||||
public void ScriptUpdate_HasTimingOptions()
|
||||
{
|
||||
@@ -44,6 +46,7 @@ public class TemplateScriptTimingTests
|
||||
|
||||
// ---- --min-time-between-runs parsing ----
|
||||
|
||||
/// <summary>Verifies that an absent minimum-time value parses as unset with no error.</summary>
|
||||
[Fact]
|
||||
public void MinTime_Absent_IsUnsetNoError()
|
||||
{
|
||||
@@ -52,6 +55,7 @@ public class TemplateScriptTimingTests
|
||||
Assert.Null(err);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a blank minimum-time value parses as unset with no error.</summary>
|
||||
[Fact]
|
||||
public void MinTime_Blank_IsUnsetNoError()
|
||||
{
|
||||
@@ -60,6 +64,9 @@ public class TemplateScriptTimingTests
|
||||
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]
|
||||
[InlineData("500ms", 500)]
|
||||
[InlineData("250MS", 250)]
|
||||
@@ -70,6 +77,8 @@ public class TemplateScriptTimingTests
|
||||
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]
|
||||
[InlineData("5")] // bare number → seconds
|
||||
[InlineData("5s")]
|
||||
@@ -82,6 +91,8 @@ public class TemplateScriptTimingTests
|
||||
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]
|
||||
[InlineData("2m")]
|
||||
[InlineData("2min")]
|
||||
@@ -93,6 +104,8 @@ public class TemplateScriptTimingTests
|
||||
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]
|
||||
[InlineData("0")]
|
||||
[InlineData("0s")]
|
||||
@@ -105,6 +118,8 @@ public class TemplateScriptTimingTests
|
||||
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]
|
||||
[InlineData("abc")]
|
||||
[InlineData("5h")] // hours not supported
|
||||
@@ -122,6 +137,7 @@ public class TemplateScriptTimingTests
|
||||
/// An absurdly large value (16-digit millisecond count) would overflow long/TimeSpan
|
||||
/// without the overflow guard. The parser must return false cleanly — not throw.
|
||||
/// </summary>
|
||||
/// <param name="value">An absurdly large duration string in milliseconds, seconds or minutes.</param>
|
||||
[Theory]
|
||||
[InlineData("9999999999999999ms")] // 16-digit ms: far exceeds TimeSpan.MaxValue (~922337203685477ms)
|
||||
[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;
|
||||
|
||||
/// <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
|
||||
/// 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.
|
||||
@@ -12,18 +12,21 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
/// </summary>
|
||||
public class TemplateTriggerKindIgnoredTests
|
||||
{
|
||||
/// <summary>Verifies that a trigger kind supplied without a trigger config is reported as ignored.</summary>
|
||||
[Fact]
|
||||
public void KindWithoutConfig_WillBeIgnored()
|
||||
{
|
||||
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]
|
||||
public void KindWithBlankConfig_WillBeIgnored()
|
||||
{
|
||||
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]
|
||||
public void KindWithConfig_NotIgnored()
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public class TemplateTriggerKindIgnoredTests
|
||||
triggerConfig: "{\"expression\":\"x > 0\"}", triggerKind: "strict"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that omitting the trigger kind is not reported as ignored.</summary>
|
||||
[Fact]
|
||||
public void NoKind_NotIgnored()
|
||||
{
|
||||
@@ -39,6 +43,7 @@ public class TemplateTriggerKindIgnoredTests
|
||||
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]
|
||||
public void NoKindWithConfig_NotIgnored()
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <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
|
||||
/// <c>"analysisKind":"Strict"</c> into the trigger-config JSON for expression
|
||||
/// triggers, matching the T28a backend contract exactly.
|
||||
@@ -17,6 +17,7 @@ public class TemplateTriggerKindTests
|
||||
// alarm-update / script-add / script-update ──────────────────────────────
|
||||
|
||||
// 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]
|
||||
public void InjectAnalysisKind_Strict_AddsKey()
|
||||
{
|
||||
@@ -29,6 +30,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// 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]
|
||||
public void InjectAnalysisKind_Advisory_RemovesKey()
|
||||
{
|
||||
@@ -41,6 +43,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// 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]
|
||||
public void InjectAnalysisKind_Strict_Idempotent()
|
||||
{
|
||||
@@ -53,6 +56,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// Null config → returns null (no config to inject into)
|
||||
/// <summary>Verifies that injecting an analysis kind into a null config returns null.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_NullConfig_ReturnsNull()
|
||||
{
|
||||
@@ -61,6 +65,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// Case-insensitive: "STRICT" → canonical "Strict"
|
||||
/// <summary>Verifies that the Strict analysis kind is recognized regardless of letter casing.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_Strict_CaseInsensitive()
|
||||
{
|
||||
@@ -73,6 +78,7 @@ public class TemplateTriggerKindTests
|
||||
}
|
||||
|
||||
// Other keys preserved when injecting
|
||||
/// <summary>Verifies that injecting an analysis kind leaves the other trigger config keys unchanged.</summary>
|
||||
[Fact]
|
||||
public void InjectAnalysisKind_OtherKeysPreserved()
|
||||
{
|
||||
@@ -88,6 +94,7 @@ public class TemplateTriggerKindTests
|
||||
|
||||
// ── Option surface: alarm add + update have --trigger-kind ───────────────
|
||||
|
||||
/// <summary>Verifies that the alarm add command exposes a trigger-kind option.</summary>
|
||||
[Fact]
|
||||
public void AlarmAdd_HasTriggerKindOption()
|
||||
{
|
||||
@@ -95,6 +102,7 @@ public class TemplateTriggerKindTests
|
||||
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the alarm update command exposes a trigger-kind option.</summary>
|
||||
[Fact]
|
||||
public void AlarmUpdate_HasTriggerKindOption()
|
||||
{
|
||||
@@ -104,6 +112,7 @@ public class TemplateTriggerKindTests
|
||||
|
||||
// ── Option surface: script add + update have --trigger-kind ─────────────
|
||||
|
||||
/// <summary>Verifies that the script add command exposes a trigger-kind option.</summary>
|
||||
[Fact]
|
||||
public void ScriptAdd_HasTriggerKindOption()
|
||||
{
|
||||
@@ -111,6 +120,7 @@ public class TemplateTriggerKindTests
|
||||
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the script update command exposes a trigger-kind option.</summary>
|
||||
[Fact]
|
||||
public void ScriptUpdate_HasTriggerKindOption()
|
||||
{
|
||||
@@ -120,6 +130,7 @@ public class TemplateTriggerKindTests
|
||||
|
||||
// ── AlarmTriggerConfigJson.Build — analysisKind injection ────────────────
|
||||
|
||||
/// <summary>Verifies that building an expression alarm config with the Strict kind injects an analysis kind of Strict.</summary>
|
||||
[Fact]
|
||||
public void AlarmConfigBuild_Expression_StrictKind_InjectsAnalysisKindStrict()
|
||||
{
|
||||
@@ -139,6 +150,7 @@ public class TemplateTriggerKindTests
|
||||
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]
|
||||
public void AlarmConfigBuild_Expression_AdvisoryKind_OmitsAnalysisKindKey()
|
||||
{
|
||||
@@ -158,6 +170,7 @@ public class TemplateTriggerKindTests
|
||||
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]
|
||||
public void AlarmConfigBuild_Expression_NullKind_OmitsAnalysisKindKey()
|
||||
{
|
||||
@@ -177,6 +190,7 @@ public class TemplateTriggerKindTests
|
||||
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]
|
||||
public void AlarmConfigBuild_NonExpression_StrictKind_DoesNotEmitAnalysisKind()
|
||||
{
|
||||
@@ -196,6 +210,7 @@ public class TemplateTriggerKindTests
|
||||
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]
|
||||
public void AlarmConfigBuild_Expression_StrictKind_CaseInsensitive()
|
||||
{
|
||||
|
||||
@@ -45,6 +45,8 @@ public class ConnectionBrowseCommandsTests
|
||||
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]
|
||||
public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier()
|
||||
{
|
||||
@@ -61,6 +63,8 @@ public class ConnectionBrowseCommandsTests
|
||||
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]
|
||||
public async Task Browse_WithoutNodeId_BrowsesFromRoot()
|
||||
{
|
||||
@@ -76,6 +80,8 @@ public class ConnectionBrowseCommandsTests
|
||||
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]
|
||||
public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier()
|
||||
{
|
||||
@@ -98,6 +104,8 @@ public class ConnectionBrowseCommandsTests
|
||||
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]
|
||||
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]
|
||||
public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier()
|
||||
{
|
||||
@@ -142,6 +152,8 @@ public class ConnectionBrowseCommandsTests
|
||||
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]
|
||||
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]
|
||||
public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier()
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <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
|
||||
/// shell history. A <c>SCADABRIDGE_PASSWORD</c> / <c>SCADABRIDGE_USERNAME</c> environment
|
||||
/// fallback gives CI/CD a safer alternative.
|
||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
[Collection("Environment")]
|
||||
public class CredentialResolutionTests
|
||||
{
|
||||
/// <summary>Verifies that the password is loaded from the environment variable.</summary>
|
||||
[Fact]
|
||||
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]
|
||||
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]
|
||||
public void Load_NoCredentialEnvVars_LeavesCredentialsNull()
|
||||
{
|
||||
|
||||
@@ -5,13 +5,14 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the testable pieces of <c>DebugCommands.StreamDebugAsync</c>:
|
||||
/// CLI-010 (Ctrl+C during connect misreported as a connection failure) and
|
||||
/// CLI-012 (non-deterministic exit code after stream termination).
|
||||
/// Ctrl+C during connect being misreported as a connection failure, and
|
||||
/// non-deterministic exit codes after stream termination.
|
||||
/// </summary>
|
||||
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]
|
||||
public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public class DebugStreamTests
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a <see cref="TaskCanceledException"/> raised after cancellation was requested is treated as a cancellation.</summary>
|
||||
[Fact]
|
||||
public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation()
|
||||
{
|
||||
@@ -34,6 +36,7 @@ public class DebugStreamTests
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a genuine connection exception is classified as a connection failure with exit code 1.</summary>
|
||||
[Fact]
|
||||
public void ClassifyConnectFailure_RealException_IsConnectionFailure()
|
||||
{
|
||||
@@ -44,6 +47,7 @@ public class DebugStreamTests
|
||||
Assert.Equal(1, result.ExitCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cancellation exception without a user cancel request is classified as a connection failure.</summary>
|
||||
[Fact]
|
||||
public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure()
|
||||
{
|
||||
@@ -56,8 +60,10 @@ public class DebugStreamTests
|
||||
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]
|
||||
public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult()
|
||||
{
|
||||
@@ -70,6 +76,8 @@ public class DebugStreamTests
|
||||
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]
|
||||
public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero()
|
||||
{
|
||||
@@ -81,6 +89,8 @@ public class DebugStreamTests
|
||||
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]
|
||||
public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,11 @@ public class DeployArtifactsExitCodeTests
|
||||
"{\"siteId\":\"site-b\",\"siteName\":\"B\",\"success\":false,\"errorMessage\":\"Secret 'k' ... missing\"}]," +
|
||||
"\"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]
|
||||
[InlineData("site", AllSucceeded, 0)]
|
||||
[InlineData("site", OneFailed, 1)]
|
||||
|
||||
@@ -8,6 +8,10 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
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]
|
||||
[InlineData("{\"successCount\":2,\"failureCount\":0}", 0)]
|
||||
[InlineData("{\"successCount\":1,\"failureCount\":1}", 1)]
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
[Collection("Console")]
|
||||
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]
|
||||
public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline()
|
||||
{
|
||||
|
||||
@@ -35,6 +35,7 @@ public class DeploymentStatusTableProjectionTests
|
||||
PageSize: rowCount),
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
/// <summary>Verifies that projection: unwraps the envelope to a row array.</summary>
|
||||
[Fact]
|
||||
public void Projection_UnwrapsTheEnvelopeToARowArray()
|
||||
{
|
||||
@@ -47,6 +48,7 @@ public class DeploymentStatusTableProjectionTests
|
||||
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]
|
||||
public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote()
|
||||
{
|
||||
@@ -59,6 +61,7 @@ public class DeploymentStatusTableProjectionTests
|
||||
Assert.Contains("--page-size", note);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that projection, when page is the whole result, emits no note column.</summary>
|
||||
[Fact]
|
||||
public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn()
|
||||
{
|
||||
@@ -69,6 +72,7 @@ public class DeploymentStatusTableProjectionTests
|
||||
e => Assert.False(e.TryGetProperty("note", out _)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that projection, when accepts a bare row array, from a pre-envelope server.</summary>
|
||||
[Fact]
|
||||
public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer()
|
||||
{
|
||||
@@ -79,6 +83,8 @@ public class DeploymentStatusTableProjectionTests
|
||||
.GetProperty("deploymentId").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that projection: leaves unrecognised bodies untouched.</summary>
|
||||
/// <param name="body">The body.</param>
|
||||
[Theory]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("""{"error":"boom"}""")]
|
||||
|
||||
@@ -24,6 +24,11 @@ public class EditingParityTests
|
||||
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]
|
||||
[InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")]
|
||||
[InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")]
|
||||
@@ -74,6 +79,8 @@ public class EditingParityTests
|
||||
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]
|
||||
[InlineData("template resolved-members")]
|
||||
[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;
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -27,12 +27,15 @@ public class ExitCodeTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_Http403_ReturnsTwo()
|
||||
{
|
||||
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]
|
||||
[InlineData("UNAUTHORIZED")]
|
||||
[InlineData("FORBIDDEN")]
|
||||
@@ -40,16 +43,18 @@ public class ExitCodeTests
|
||||
public void HandleResponse_AuthorizationCode_NonForbiddenStatus_ReturnsTwo(string code)
|
||||
{
|
||||
// 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)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a generic error returns exit code 1.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_GenericError_ReturnsOne()
|
||||
{
|
||||
Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT")));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an authentication failure returns exit code 1.</summary>
|
||||
[Fact]
|
||||
public void HandleResponse_AuthenticationFailure_ReturnsOne()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user