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
|
/// cleanly on another. Free-text columns are run through
|
||||||
/// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a
|
/// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a
|
||||||
/// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as
|
/// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as
|
||||||
/// a formula or DDE trigger by a spreadsheet application (CWE-1236). GUID/enum/numeric/date/bool columns this
|
/// a formula or DDE trigger by a spreadsheet application (CSV formula injection). GUID/enum/numeric/date/bool columns this
|
||||||
/// formatter generates itself are never neutralized.
|
/// formatter generates itself are never neutralized.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="evt">The audit event view to format as a CSV row.</param>
|
/// <param name="evt">The audit event view to format as a CSV row.</param>
|
||||||
@@ -228,7 +228,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OWASP CSV-injection neutralization (CWE-1236): prefix a literal single
|
// OWASP CSV-injection neutralization: prefix a literal single
|
||||||
// quote before RFC 4180 quoting so a free-text cell starting with a
|
// quote before RFC 4180 quoting so a free-text cell starting with a
|
||||||
// formula/DDE trigger character renders as inert text in Excel.
|
// formula/DDE trigger character renders as inert text in Excel.
|
||||||
if (freeText && NeedsFormulaNeutralization(value))
|
if (freeText && NeedsFormulaNeutralization(value))
|
||||||
|
|||||||
@@ -708,7 +708,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Arms the backoff retry for a failed fan-out: <c>reconnectDelay × 2^(failures-1)</c>,
|
/// Arms the backoff retry for a failed fan-out: <c>reconnectDelay × 2^(failures − 1)</c>,
|
||||||
/// capped at <see cref="MaxSeedRetryBackoffMultiplier"/> × the reconcile interval. Once
|
/// capped at <see cref="MaxSeedRetryBackoffMultiplier"/> × the reconcile interval. Once
|
||||||
/// the cap is reached the periodic reconcile tick is doing the same work anyway, so no
|
/// the cap is reached the periodic reconcile tick is doing the same work anyway, so no
|
||||||
/// extra timer is armed.
|
/// extra timer is armed.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public sealed record SiteHealthState
|
|||||||
/// <c>null</c> if the site is known only via heartbeats and has not yet sent
|
/// <c>null</c> if the site is known only via heartbeats and has not yet sent
|
||||||
/// a report. Used by the UI to surface report staleness during failover;
|
/// a report. Used by the UI to surface report staleness during failover;
|
||||||
/// the <c>null</c> case must be rendered as "no report yet" rather than as a
|
/// the <c>null</c> case must be rendered as "no report yet" rather than as a
|
||||||
/// timestamp (a <c>default</c> sentinel would display as year-0001).
|
/// timestamp (a <c>default</c> sentinel would display as year 0001).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTimeOffset? LastReportReceivedAt { get; init; }
|
public DateTimeOffset? LastReportReceivedAt { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -2560,7 +2560,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
|
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
|
||||||
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
|
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
|
||||||
// Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an
|
// Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an
|
||||||
// omitted authority/scope leaves the stored value (or its M365 default) intact.
|
// omitted authority/scope leaves the stored value (or its Microsoft 365 default) intact.
|
||||||
if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
|
if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
|
||||||
if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
|
if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
|
||||||
// Preserve-if-null for the transport too: an omitted value keeps the stored
|
// Preserve-if-null for the transport too: an omitted value keeps the stored
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle E (M2 Task E1) DI surface tests for <c>AddAuditLog</c>. M1 shipped
|
/// DI surface tests for <c>AddAuditLog</c>. The initial version was an
|
||||||
/// the options-only scaffold; M2 extends it with the site writer chain
|
/// options-only scaffold; it was then extended with the site writer chain
|
||||||
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
||||||
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
|
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
|
||||||
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
|
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
|
||||||
@@ -41,6 +41,8 @@ public class AddAuditLogTests
|
|||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog registers the audit log options.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_RegistersAuditLogOptions()
|
public void AddAuditLog_RegistersAuditLogOptions()
|
||||||
{
|
{
|
||||||
@@ -52,6 +54,8 @@ public class AddAuditLogTests
|
|||||||
Assert.NotNull(opts!.Value);
|
Assert.NotNull(opts!.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog throws when the service collection is null.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_NullServices_Throws()
|
public void AddAuditLog_NullServices_Throws()
|
||||||
{
|
{
|
||||||
@@ -61,6 +65,8 @@ public class AddAuditLogTests
|
|||||||
() => ServiceCollectionExtensions.AddAuditLog(null!, config));
|
() => ServiceCollectionExtensions.AddAuditLog(null!, config));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog throws when the configuration is null.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_NullConfig_Throws()
|
public void AddAuditLog_NullConfig_Throws()
|
||||||
{
|
{
|
||||||
@@ -70,8 +76,10 @@ public class AddAuditLogTests
|
|||||||
() => services.AddAuditLog(null!));
|
() => services.AddAuditLog(null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Bundle E (M2 Task E1) ---------------------------------------------
|
// -- Site writer chain DI registrations ---------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog registers the SQLite audit writer as a singleton resolvable from DI.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
|
public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
|
||||||
{
|
{
|
||||||
@@ -90,6 +98,8 @@ public class AddAuditLogTests
|
|||||||
Assert.Same(writer, provider.GetService<SqliteAuditWriter>());
|
Assert.Same(writer, provider.GetService<SqliteAuditWriter>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog registers IAuditWriter as the fallback audit writer.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
|
public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
|
||||||
{
|
{
|
||||||
@@ -104,6 +114,8 @@ public class AddAuditLogTests
|
|||||||
Assert.IsType<FallbackAuditWriter>(writer);
|
Assert.IsType<FallbackAuditWriter>(writer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog registers ISiteAuditQueue as the same instance as the SQLite audit writer.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
|
public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
|
||||||
{
|
{
|
||||||
@@ -123,6 +135,8 @@ public class AddAuditLogTests
|
|||||||
Assert.Same(writer, queue);
|
Assert.Same(writer, queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog registers the ring-buffer fallback as a singleton.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Registers_RingBufferFallback_Singleton()
|
public void AddAuditLog_Registers_RingBufferFallback_Singleton()
|
||||||
{
|
{
|
||||||
@@ -136,6 +150,8 @@ public class AddAuditLogTests
|
|||||||
Assert.Same(ring, provider.GetService<RingBufferFallback>());
|
Assert.Same(ring, provider.GetService<RingBufferFallback>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog registers a no-op audit write failure counter by default.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
|
public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
|
||||||
{
|
{
|
||||||
@@ -149,6 +165,8 @@ public class AddAuditLogTests
|
|||||||
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
|
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog registers a no-op site stream audit client by default.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
|
public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
|
||||||
{
|
{
|
||||||
@@ -162,8 +180,10 @@ public class AddAuditLogTests
|
|||||||
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- M4 Bundle B (B1) central direct-write audit writer -----------------
|
// -- Central direct-write audit writer -----------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that AddAuditLog registers ICentralAuditWriter as the central audit writer.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter()
|
public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter()
|
||||||
{
|
{
|
||||||
@@ -177,6 +197,8 @@ public class AddAuditLogTests
|
|||||||
Assert.IsType<CentralAuditWriter>(writer);
|
Assert.IsType<CentralAuditWriter>(writer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the ICentralAuditWriter registration is a singleton.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_ICentralAuditWriter_IsSingleton()
|
public void AddAuditLog_ICentralAuditWriter_IsSingleton()
|
||||||
{
|
{
|
||||||
@@ -190,6 +212,8 @@ public class AddAuditLogTests
|
|||||||
Assert.Same(w1, w2);
|
Assert.Same(w1, w2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the SQLite writer options bind from configuration and round-trip.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
|
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
|
||||||
{
|
{
|
||||||
@@ -208,6 +232,8 @@ public class AddAuditLogTests
|
|||||||
Assert.Equal(75, opts.FlushIntervalMs);
|
Assert.Equal(75, opts.FlushIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the site telemetry options bind from configuration and round-trip.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
|
public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
|
||||||
{
|
{
|
||||||
@@ -224,8 +250,10 @@ public class AddAuditLogTests
|
|||||||
Assert.Equal(60, opts.IdleIntervalSeconds);
|
Assert.Equal(60, opts.IdleIntervalSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Bundle G (M2 Task G1) Site Health Monitoring bridge ----------------
|
// -- Site Health Monitoring bridge ----------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the health metrics bridge swaps the failure counter for the health metrics implementation.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLogHealthMetricsBridge_Swaps_FailureCounter_To_HealthMetrics_Implementation()
|
public void AddAuditLogHealthMetricsBridge_Swaps_FailureCounter_To_HealthMetrics_Implementation()
|
||||||
{
|
{
|
||||||
@@ -251,6 +279,8 @@ public class AddAuditLogTests
|
|||||||
Assert.IsType<HealthMetricsAuditWriteFailureCounter>(counter);
|
Assert.IsType<HealthMetricsAuditWriteFailureCounter>(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the health metrics bridge still resolves without health monitoring but errors when used.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLogHealthMetricsBridge_Without_HealthMonitoring_Still_Resolves_But_Errors_On_Use()
|
public void AddAuditLogHealthMetricsBridge_Without_HealthMonitoring_Still_Resolves_But_Errors_On_Use()
|
||||||
{
|
{
|
||||||
@@ -276,10 +306,12 @@ public class AddAuditLogTests
|
|||||||
() => provider.GetRequiredService<IAuditWriteFailureCounter>());
|
() => provider.GetRequiredService<IAuditWriteFailureCounter>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that registering the health metrics bridge twice does not register the hosted service twice.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService()
|
public void AddAuditLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService()
|
||||||
{
|
{
|
||||||
// AuditLog-011: AddHostedService has no TryAdd variant, so a second
|
// AddHostedService has no TryAdd variant, so a second
|
||||||
// call without the sentinel guard would spin up a second
|
// call without the sentinel guard would spin up a second
|
||||||
// SiteAuditBacklogReporter on the same SQLite file. The helper must
|
// SiteAuditBacklogReporter on the same SQLite file. The helper must
|
||||||
// be a no-op on the second call — exactly one hosted-service
|
// be a no-op on the second call — exactly one hosted-service
|
||||||
|
|||||||
+63
-8
@@ -19,9 +19,9 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>'s M3 combined-
|
/// Tests for <see cref="AuditLogIngestActor"/>'s combined-telemetry
|
||||||
/// telemetry dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
|
/// dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
|
||||||
/// as the M1 + M2 repository tests so the actor exercises real
|
/// as the audit-log and site-call repository tests so the actor exercises real
|
||||||
/// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> +
|
/// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> +
|
||||||
/// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL
|
/// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL
|
||||||
/// database. The transaction commits or rolls back inside one
|
/// database. The transaction commits or rolls back inside one
|
||||||
@@ -31,6 +31,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||||
public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture)
|
public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -125,6 +127,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
serviceProvider,
|
serviceProvider,
|
||||||
NullLogger<AuditLogIngestActor>.Instance)));
|
NullLogger<AuditLogIngestActor>.Instance)));
|
||||||
|
|
||||||
|
/// <summary>Verifies that one cached-telemetry packet writes both an audit row and a site-call row and acknowledges the event id.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId()
|
public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId()
|
||||||
{
|
{
|
||||||
@@ -156,6 +160,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
Assert.Equal(siteCall.Status, siteCallRow!.Status);
|
Assert.Equal(siteCall.Status, siteCallRow!.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a duplicate event id with an unchanged status is a no-op that leaves the row counts unchanged and still acknowledges the id.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId()
|
public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId()
|
||||||
{
|
{
|
||||||
@@ -195,6 +201,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
Assert.Equal(1, siteCallCount);
|
Assert.Equal(1, siteCallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a duplicate event id carrying an advanced site-call status updates the existing site-call row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall()
|
public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall()
|
||||||
{
|
{
|
||||||
@@ -206,7 +214,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
var sp = BuildServiceProvider();
|
var sp = BuildServiceProvider();
|
||||||
var actor = CreateActor(sp);
|
var actor = CreateActor(sp);
|
||||||
|
|
||||||
// 1st packet — Submitted (audit EventId #1, SiteCalls Status=Submitted).
|
// 1st packet — Submitted (first audit EventId, SiteCalls Status=Submitted).
|
||||||
var (auditSubmit, siteCallSubmit) = NewEntry(
|
var (auditSubmit, siteCallSubmit) = NewEntry(
|
||||||
siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted);
|
siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted);
|
||||||
actor.Tell(
|
actor.Tell(
|
||||||
@@ -214,7 +222,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
TestActor);
|
TestActor);
|
||||||
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
|
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
// 2nd packet — Attempted with retry count 1 (audit EventId #2,
|
// 2nd packet — Attempted with retry count 1 (second audit EventId,
|
||||||
// SiteCalls Status=Attempted — monotonic upsert wins). Same
|
// SiteCalls Status=Attempted — monotonic upsert wins). Same
|
||||||
// TrackedOperationId throughout.
|
// TrackedOperationId throughout.
|
||||||
var (auditAttempt, siteCallAttempt) = NewEntry(
|
var (auditAttempt, siteCallAttempt) = NewEntry(
|
||||||
@@ -242,6 +250,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
Assert.Equal(1, siteCallRow.RetryCount);
|
Assert.Equal(1, siteCallRow.RetryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that when the site-call upsert throws after the audit insert succeeds, both writes are rolled back and no orphan audit row remains.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow()
|
public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow()
|
||||||
{
|
{
|
||||||
@@ -273,6 +283,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
Assert.Null(siteCallRow);
|
Assert.Null(siteCallRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that five cached-telemetry packets are each persisted separately and all are acknowledged.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked()
|
public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked()
|
||||||
{
|
{
|
||||||
@@ -303,6 +315,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
Assert.Equal(5, siteCallCount);
|
Assert.Equal(5, siteCallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a packet failing after an earlier packet succeeded leaves the first one committed and lets the batch continue.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues()
|
public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues()
|
||||||
{
|
{
|
||||||
@@ -348,7 +362,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// arch-review 2026-08-19 P5-1: the per-entry fallback loop (used when the
|
/// The per-entry fallback loop (used when the
|
||||||
/// batch fast path fails) must run under its own short
|
/// batch fast path fails) must run under its own short
|
||||||
/// <see cref="AuditLogIngestActor.IngestFallbackBudget"/>, not an unbounded
|
/// <see cref="AuditLogIngestActor.IngestFallbackBudget"/>, not an unbounded
|
||||||
/// wait — otherwise a single slow/wedged downstream call can freeze this
|
/// wait — otherwise a single slow/wedged downstream call can freeze this
|
||||||
@@ -368,6 +382,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
/// <c>ExpectMsg</c> below would time out and fail, which is the fail-unfixed
|
/// <c>ExpectMsg</c> below would time out and fail, which is the fail-unfixed
|
||||||
/// proof for this fix.
|
/// proof for this fix.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_CachedTelemetry_PerEntryFallbackHangs_BudgetBoundsWait_RemainderSkipped()
|
public async Task Receive_CachedTelemetry_PerEntryFallbackHangs_BudgetBoundsWait_RemainderSkipped()
|
||||||
{
|
{
|
||||||
@@ -430,38 +445,51 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class BatchFailingAuditRepo : IAuditLogRepository
|
private sealed class BatchFailingAuditRepo : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
private readonly IAuditLogRepository _inner;
|
private readonly IAuditLogRepository _inner;
|
||||||
|
/// <summary>Initializes the double with the real repository that every non-failing member delegates to.</summary>
|
||||||
|
/// <param name="inner">The real repository to delegate to.</param>
|
||||||
public BatchFailingAuditRepo(IAuditLogRepository inner) { _inner = inner; }
|
public BatchFailingAuditRepo(IAuditLogRepository inner) { _inner = inner; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<int> InsertManyIfNotExistsAsync(
|
public Task<int> InsertManyIfNotExistsAsync(
|
||||||
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
throw new InvalidOperationException("simulated batch ingest failure — forces the per-entry fallback");
|
throw new InvalidOperationException("simulated batch ingest failure — forces the per-entry fallback");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||||
_inner.InsertIfNotExistsAsync(evt, ct);
|
_inner.InsertIfNotExistsAsync(evt, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||||
_inner.QueryAsync(filter, paging, ct);
|
_inner.QueryAsync(filter, paging, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(
|
public Task<long> SwitchOutPartitionAsync(
|
||||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||||
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||||
_inner.GetExecutionTreeAsync(executionId, ct);
|
_inner.GetExecutionTreeAsync(executionId, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||||
_inner.GetDistinctSourceNodesAsync(ct);
|
_inner.GetDistinctSourceNodesAsync(ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
|
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
|
||||||
@@ -471,15 +499,17 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
/// Test double — <see cref="UpsertAsync"/> hangs on
|
/// Test double — <see cref="UpsertAsync"/> hangs on
|
||||||
/// <c>Task.Delay(Timeout.InfiniteTimeSpan, ct)</c> until the caller's token
|
/// <c>Task.Delay(Timeout.InfiniteTimeSpan, ct)</c> until the caller's token
|
||||||
/// cancels, recording the call count and the token it was handed. Used to
|
/// cancels, recording the call count and the token it was handed. Used to
|
||||||
/// prove the per-entry fallback loop's own budget bounds the wait (arch-review
|
/// prove the per-entry fallback loop's own budget bounds the wait rather blocking forever on a wedged downstream call.
|
||||||
/// 2026-08-19 P5-1) rather than blocking forever on a wedged downstream call.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class HangingSiteCallRepo : ISiteCallAuditRepository
|
private sealed class HangingSiteCallRepo : ISiteCallAuditRepository
|
||||||
{
|
{
|
||||||
private int _callCount;
|
private int _callCount;
|
||||||
|
/// <summary>Gets the number of times <see cref="UpsertAsync"/> has been invoked.</summary>
|
||||||
public int CallCount => _callCount;
|
public int CallCount => _callCount;
|
||||||
|
/// <summary>Gets the cancellation token most recently passed to <see cref="UpsertAsync"/>.</summary>
|
||||||
public CancellationToken? ReceivedToken { get; private set; }
|
public CancellationToken? ReceivedToken { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _callCount);
|
Interlocked.Increment(ref _callCount);
|
||||||
@@ -487,19 +517,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
|
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
@@ -513,22 +549,31 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository
|
private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository
|
||||||
{
|
{
|
||||||
private readonly ISiteCallAuditRepository _inner;
|
private readonly ISiteCallAuditRepository _inner;
|
||||||
|
/// <summary>Initializes the double with the real repository that read members delegate to.</summary>
|
||||||
|
/// <param name="inner">The real repository to delegate to.</param>
|
||||||
public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; }
|
public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; }
|
||||||
|
/// <inheritdoc />
|
||||||
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
||||||
throw new InvalidOperationException("simulated SiteCalls upsert failure");
|
throw new InvalidOperationException("simulated SiteCalls upsert failure");
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||||
_inner.GetAsync(id, ct);
|
_inner.GetAsync(id, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||||
_inner.QueryAsync(filter, paging, ct);
|
_inner.QueryAsync(filter, paging, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
@@ -543,11 +588,15 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
{
|
{
|
||||||
private readonly ISiteCallAuditRepository _inner;
|
private readonly ISiteCallAuditRepository _inner;
|
||||||
private readonly TrackedOperationId _poisonId;
|
private readonly TrackedOperationId _poisonId;
|
||||||
|
/// <summary>Initializes the double with the real repository and the id whose upsert should fail.</summary>
|
||||||
|
/// <param name="inner">The real repository to delegate to.</param>
|
||||||
|
/// <param name="poisonId">The tracked operation id whose upsert throws.</param>
|
||||||
public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
|
public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
|
||||||
{
|
{
|
||||||
_inner = inner;
|
_inner = inner;
|
||||||
_poisonId = poisonId;
|
_poisonId = poisonId;
|
||||||
}
|
}
|
||||||
|
/// <inheritdoc />
|
||||||
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (siteCall.TrackedOperationId == _poisonId)
|
if (siteCall.TrackedOperationId == _poisonId)
|
||||||
@@ -556,19 +605,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
}
|
}
|
||||||
return _inner.UpsertAsync(siteCall, ct);
|
return _inner.UpsertAsync(siteCall, ct);
|
||||||
}
|
}
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||||
_inner.GetAsync(id, ct);
|
_inner.GetAsync(id, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||||
_inner.QueryAsync(filter, paging, ct);
|
_inner.QueryAsync(filter, paging, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>. Uses the same
|
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>. Uses the same
|
||||||
/// <see cref="MsSqlMigrationFixture"/> as the M1 repository tests so the actor
|
/// <see cref="MsSqlMigrationFixture"/> as the repository tests so the actor
|
||||||
/// exercises real <see cref="AuditLogRepository.InsertIfNotExistsAsync"/>
|
/// exercises real <see cref="AuditLogRepository.InsertIfNotExistsAsync"/>
|
||||||
/// against a partitioned MSSQL schema (the only way to verify the
|
/// against a partitioned MSSQL schema (the only way to verify the
|
||||||
/// IngestedAtUtc stamp + duplicate-key idempotency end to end).
|
/// IngestedAtUtc stamp + duplicate-key idempotency end to end).
|
||||||
@@ -27,6 +27,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||||
public AuditLogIngestActorTests(MsSqlMigrationFixture fixture)
|
public AuditLogIngestActorTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -61,6 +63,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
NullLogger<AuditLogIngestActor>.Instance)));
|
NullLogger<AuditLogIngestActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a batch of five events calls the repository five times and acknowledges all five.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5()
|
public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5()
|
||||||
{
|
{
|
||||||
@@ -87,6 +91,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
Assert.Equal(5, rows.Count);
|
Assert.Equal(5, rows.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a batch containing an already-existing event acknowledges every event without inserting a duplicate.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert()
|
public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert()
|
||||||
{
|
{
|
||||||
@@ -126,12 +132,14 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
Assert.Equal(3, count);
|
Assert.Equal(3, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that duplicate event ids within one packet produce a single row and are all acknowledged.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_DuplicateEventIds_WithinOnePacket_ProduceOneRow_AndAreAllAcked()
|
public async Task Receive_DuplicateEventIds_WithinOnePacket_ProduceOneRow_AndAreAllAcked()
|
||||||
{
|
{
|
||||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
// WP2.2 writes each packet as ONE set-based statement, whose anti-semi-join
|
// The repository writes each packet as ONE set-based statement, whose anti-semi-join
|
||||||
// only sees already-COMMITTED rows. Two copies of an EventId inside one
|
// only sees already-COMMITTED rows. Two copies of an EventId inside one
|
||||||
// packet would therefore both pass it and collide on the clustered PK,
|
// packet would therefore both pass it and collide on the clustered PK,
|
||||||
// taking the whole packet down — the repository de-duplicates first.
|
// taking the whole packet down — the repository de-duplicates first.
|
||||||
@@ -161,6 +169,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
Assert.Equal(2, rows.Count);
|
Assert.Equal(2, rows.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that sending the same packet twice is idempotent across packets.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets()
|
public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets()
|
||||||
{
|
{
|
||||||
@@ -194,6 +204,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
Assert.Equal(6, rows.Count);
|
Assert.Equal(6, rows.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that overlapping packets insert only the new rows.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows()
|
public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows()
|
||||||
{
|
{
|
||||||
@@ -225,6 +237,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
Assert.Equal(7, rows.Count);
|
Assert.Equal(7, rows.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the actor stamps IngestedAtUtc before inserting an event.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_Sets_IngestedAtUtc_Before_Insert()
|
public async Task Receive_Sets_IngestedAtUtc_Before_Insert()
|
||||||
{
|
{
|
||||||
@@ -257,6 +271,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that when the repository throws for one event, the other four are still persisted.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted()
|
public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted()
|
||||||
{
|
{
|
||||||
@@ -289,10 +305,12 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
Assert.DoesNotContain(rows, r => r.EventId == poisonId);
|
Assert.DoesNotContain(rows, r => r.EventId == poisonId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that when repository resolution throws, the actor survives, replies with an empty acknowledgement, and counts the failure.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Receive_WhenRepositoryResolutionThrows_ActorSurvives_RepliesEmpty_CountsFailure()
|
public async Task Receive_WhenRepositoryResolutionThrows_ActorSurvives_RepliesEmpty_CountsFailure()
|
||||||
{
|
{
|
||||||
// AuditLog-017 (covers the AuditLog-014 guard): the production ctor resolves the
|
// The production ctor resolves the
|
||||||
// scoped repository per message. If scope creation / repository resolution throws
|
// scoped repository per message. If scope creation / repository resolution throws
|
||||||
// (transient DI or DbContext-factory fault, pooled-context init, a resolution race
|
// (transient DI or DbContext-factory fault, pooled-context init, a resolution race
|
||||||
// during host churn), the outer guard must keep the singleton ALIVE, increment the
|
// during host churn), the outer guard must keep the singleton ALIVE, increment the
|
||||||
@@ -343,6 +361,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
/// source, so "not equal" IS "a fresh CTS", and a fresh CTS cannot be
|
/// source, so "not equal" IS "a fresh CTS", and a fresh CTS cannot be
|
||||||
/// pre-cancelled by the batch.
|
/// pre-cancelled by the batch.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Receive_WhenBatchFails_PerRowFallbackUsesAFreshToken()
|
public async Task Receive_WhenBatchFails_PerRowFallbackUsesAFreshToken()
|
||||||
{
|
{
|
||||||
@@ -371,7 +390,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
/// <summary>Counts how many times the guard's catch surfaced a write failure.</summary>
|
/// <summary>Counts how many times the guard's catch surfaced a write failure.</summary>
|
||||||
private sealed class CountingFailureCounter : ICentralAuditWriteFailureCounter
|
private sealed class CountingFailureCounter : ICentralAuditWriteFailureCounter
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the number of failures counted so far.</summary>
|
||||||
public int Count { get; private set; }
|
public int Count { get; private set; }
|
||||||
|
/// <inheritdoc />
|
||||||
public void Increment() => Count++;
|
public void Increment() => Count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,13 +403,17 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class TokenRecordingRepository : IAuditLogRepository
|
private sealed class TokenRecordingRepository : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
|
/// <summary>Gets the cancellation token handed to the set-based batch insert.</summary>
|
||||||
public CancellationToken? BatchToken { get; private set; }
|
public CancellationToken? BatchToken { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Gets the cancellation tokens handed to each per-row insert.</summary>
|
||||||
public List<CancellationToken> RowTokens { get; } = new();
|
public List<CancellationToken> RowTokens { get; } = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<int> InsertManyIfNotExistsAsync(
|
public Task<int> InsertManyIfNotExistsAsync(
|
||||||
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -396,40 +421,49 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
throw new OperationCanceledException("simulated ingest-budget expiry", ct);
|
throw new OperationCanceledException("simulated ingest-budget expiry", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
RowTokens.Add(ct);
|
RowTokens.Add(ct);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(
|
public Task<long> SwitchOutPartitionAsync(
|
||||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
@@ -441,18 +475,23 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class ThrowingRepository : IAuditLogRepository
|
private sealed class ThrowingRepository : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
private readonly IAuditLogRepository _inner;
|
private readonly IAuditLogRepository _inner;
|
||||||
private readonly Guid _poisonId;
|
private readonly Guid _poisonId;
|
||||||
|
|
||||||
|
/// <summary>Initializes the double with an inner repository and the id of the event that must fail.</summary>
|
||||||
|
/// <param name="inner">The real repository to delegate to.</param>
|
||||||
|
/// <param name="poisonId">The event id for which inserts throw.</param>
|
||||||
public ThrowingRepository(IAuditLogRepository inner, Guid poisonId)
|
public ThrowingRepository(IAuditLogRepository inner, Guid poisonId)
|
||||||
{
|
{
|
||||||
_inner = inner;
|
_inner = inner;
|
||||||
_poisonId = poisonId;
|
_poisonId = poisonId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (evt.EventId == _poisonId)
|
if (evt.EventId == _poisonId)
|
||||||
@@ -462,33 +501,41 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
return _inner.InsertIfNotExistsAsync(evt, ct);
|
return _inner.InsertIfNotExistsAsync(evt, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||||
_inner.QueryAsync(filter, paging, ct);
|
_inner.QueryAsync(filter, paging, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
|
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||||
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||||
_inner.GetExecutionTreeAsync(executionId, ct);
|
_inner.GetExecutionTreeAsync(executionId, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||||
_inner.GetDistinctSourceNodesAsync(ct);
|
_inner.GetDistinctSourceNodesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogPartitionMaintenanceOptionsValidatorTests
|
public class AuditLogPartitionMaintenanceOptionsValidatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the default options pass validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultOptions_AreValid()
|
public void DefaultOptions_AreValid()
|
||||||
{
|
{
|
||||||
@@ -17,6 +18,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded);
|
Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero interval fails validation and names the interval property.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroIntervalSeconds_Fails()
|
public void ZeroIntervalSeconds_Fails()
|
||||||
{
|
{
|
||||||
@@ -28,6 +30,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal));
|
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero look-ahead fails validation and names the look-ahead property.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroLookaheadMonths_Fails()
|
public void ZeroLookaheadMonths_Fails()
|
||||||
{
|
{
|
||||||
|
|||||||
+25
-2
@@ -9,7 +9,7 @@ using Xunit;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle D (#23 M6-T5) tests for <see cref="AuditLogPartitionMaintenanceService"/>.
|
/// Tests for <see cref="AuditLogPartitionMaintenanceService"/>.
|
||||||
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
|
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
|
||||||
/// the real EF/MSSQL implementation is exercised by the
|
/// the real EF/MSSQL implementation is exercised by the
|
||||||
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
|
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
|
||||||
@@ -28,6 +28,7 @@ public class AuditLogPartitionMaintenanceServiceTests
|
|||||||
public int EnsureCallCount;
|
public int EnsureCallCount;
|
||||||
public Exception? ThrowOnce;
|
public Exception? ThrowOnce;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
|
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref EnsureCallCount);
|
Interlocked.Increment(ref EnsureCallCount);
|
||||||
@@ -39,6 +40,7 @@ public class AuditLogPartitionMaintenanceServiceTests
|
|||||||
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
|
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
|
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
|
||||||
}
|
}
|
||||||
@@ -50,12 +52,27 @@ public class AuditLogPartitionMaintenanceServiceTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
|
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the log entries captured so far.</summary>
|
||||||
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Returns no scope; the fake does not track scopes.</summary>
|
||||||
|
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||||
|
/// <param name="state">The scope state (ignored).</param>
|
||||||
|
/// <returns>Always null.</returns>
|
||||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
|
||||||
|
/// <summary>Reports every log level as enabled.</summary>
|
||||||
|
/// <param name="logLevel">The log level to check.</param>
|
||||||
|
/// <returns>Always true.</returns>
|
||||||
public bool IsEnabled(LogLevel logLevel) => true;
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
/// <summary>Records the log entry for later assertions.</summary>
|
||||||
|
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||||
|
/// <param name="logLevel">The severity of the entry.</param>
|
||||||
|
/// <param name="eventId">The event identifier.</param>
|
||||||
|
/// <param name="state">The state object to format.</param>
|
||||||
|
/// <param name="exception">The exception associated with the entry, if any.</param>
|
||||||
|
/// <param name="formatter">Formats the state and exception into the message.</param>
|
||||||
public void Log<TState>(
|
public void Log<TState>(
|
||||||
LogLevel logLevel,
|
LogLevel logLevel,
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
@@ -77,6 +94,8 @@ public class AuditLogPartitionMaintenanceServiceTests
|
|||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the service starts and stops without throwing.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartStop_NoExceptions()
|
public async Task StartStop_NoExceptions()
|
||||||
{
|
{
|
||||||
@@ -113,6 +132,8 @@ public class AuditLogPartitionMaintenanceServiceTests
|
|||||||
Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
|
Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an exception thrown during maintenance is logged and not propagated.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SafeMaintain_ExceptionLogged_NotPropagated()
|
public async Task SafeMaintain_ExceptionLogged_NotPropagated()
|
||||||
{
|
{
|
||||||
@@ -152,10 +173,12 @@ public class AuditLogPartitionMaintenanceServiceTests
|
|||||||
Assert.Equal(1, maintenance.EnsureCallCount);
|
Assert.Equal(1, maintenance.EnsureCallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that stopping the service after it has been disposed does not throw.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
||||||
{
|
{
|
||||||
// Regression (Gitea #15 follow-up): Dispose tears down the CTS that
|
// Regression: Dispose tears down the CTS that
|
||||||
// StopAsync cancels, and the host does not guarantee that every
|
// StopAsync cancels, and the host does not guarantee that every
|
||||||
// IHostedService.StopAsync is driven before the DI container is
|
// IHostedService.StopAsync is driven before the DI container is
|
||||||
// disposed — WebApplicationFactory's teardown reaches Dispose first and
|
// disposed — WebApplicationFactory's teardown reaches Dispose first and
|
||||||
|
|||||||
@@ -18,18 +18,20 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle C (#23 M6-T4) tests for <see cref="AuditLogPurgeActor"/>. The fast,
|
/// Tests for <see cref="AuditLogPurgeActor"/>. The fast,
|
||||||
/// schedule-only tests substitute a recording stub for
|
/// schedule-only tests substitute a recording stub for
|
||||||
/// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation
|
/// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation
|
||||||
/// + event-publish machinery can be exercised without an MSSQL container.
|
/// + event-publish machinery can be exercised without an MSSQL container.
|
||||||
/// The end-to-end "real partition gets switched out" assertion lives in the
|
/// The end-to-end "real partition gets switched out" assertion lives in the
|
||||||
/// repository tests (Bundle C of M6-T4); this actor file is purely about the
|
/// repository tests; this actor file is purely about the
|
||||||
/// actor's policy decisions.
|
/// actor's policy decisions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||||
public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture)
|
public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -45,38 +47,52 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class RecordingRepo : IAuditLogRepository
|
private sealed class RecordingRepo : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
|
/// <summary>Gets the thresholds passed to each partition-boundary query.</summary>
|
||||||
public List<DateTime> ThresholdQueries { get; } = new();
|
public List<DateTime> ThresholdQueries { get; } = new();
|
||||||
|
/// <summary>Gets the partition boundaries that were switched out, in call order.</summary>
|
||||||
public List<DateTime> SwitchedBoundaries { get; } = new();
|
public List<DateTime> SwitchedBoundaries { get; } = new();
|
||||||
|
/// <summary>Gets or sets the function that returns the row count reported for a switched boundary.</summary>
|
||||||
public Func<DateTime, long> RowsPerBoundary { get; set; } = _ => 0L;
|
public Func<DateTime, long> RowsPerBoundary { get; set; } = _ => 0L;
|
||||||
|
/// <summary>Gets or sets the boundary whose switch-out should throw, if any.</summary>
|
||||||
public DateTime? ThrowOnBoundary { get; set; }
|
public DateTime? ThrowOnBoundary { get; set; }
|
||||||
|
/// <summary>Gets or sets the exception thrown for <see cref="ThrowOnBoundary"/>; a default failure is used when null.</summary>
|
||||||
public Exception? BoundaryException { get; set; }
|
public Exception? BoundaryException { get; set; }
|
||||||
|
|
||||||
// Task 4: captures the maintenance command timeout the actor threads through to the
|
// Captures the maintenance command timeout the actor threads through to the
|
||||||
// switch-out + per-channel purge, so tests can assert the options value reaches the repo.
|
// switch-out + per-channel purge, so tests can assert the options value reaches the repo.
|
||||||
|
/// <summary>Gets the command timeouts passed to each partition switch-out call.</summary>
|
||||||
public List<TimeSpan?> SwitchTimeouts { get; } = new();
|
public List<TimeSpan?> SwitchTimeouts { get; } = new();
|
||||||
|
/// <summary>Gets the command timeouts passed to each per-channel purge call.</summary>
|
||||||
public List<TimeSpan?> ChannelTimeouts { get; } = new();
|
public List<TimeSpan?> ChannelTimeouts { get; } = new();
|
||||||
|
|
||||||
// M5.5 (T3): records every per-channel purge call as
|
// Records every per-channel purge call as
|
||||||
// (channel, threshold, batchSize) so tests can assert which channels the
|
// (channel, threshold, batchSize) so tests can assert which channels the
|
||||||
// actor chose to purge and with what window.
|
// actor chose to purge and with what window.
|
||||||
|
/// <summary>Gets every per-channel purge call as a channel, threshold, batch size and row ceiling.</summary>
|
||||||
public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new();
|
public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new();
|
||||||
|
/// <summary>Gets or sets the function that returns the row count reported for a purged channel.</summary>
|
||||||
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
|
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
|
||||||
|
|
||||||
// The actor enumerator returns whichever list is configured here.
|
// The actor enumerator returns whichever list is configured here.
|
||||||
// Mutating this between ticks lets tests simulate "no longer
|
// Mutating this between ticks lets tests simulate "no longer
|
||||||
// eligible" boundaries on the second tick.
|
// eligible" boundaries on the second tick.
|
||||||
|
/// <summary>Gets or sets the partition boundaries returned by the next threshold query.</summary>
|
||||||
public List<DateTime> Boundaries { get; set; } = new();
|
public List<DateTime> Boundaries { get; set; } = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
SwitchTimeouts.Add(commandTimeout);
|
SwitchTimeouts.Add(commandTimeout);
|
||||||
@@ -88,6 +104,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
return Task.FromResult(RowsPerBoundary(monthBoundary));
|
return Task.FromResult(RowsPerBoundary(monthBoundary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default)
|
DateTime threshold, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -95,6 +112,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
|
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
|
||||||
{
|
{
|
||||||
@@ -103,18 +121,22 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
return Task.FromResult(RowsPerChannel(channel));
|
return Task.FromResult(RowsPerChannel(channel));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||||
}
|
}
|
||||||
@@ -194,6 +216,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// 1. Tick_Fires_OnDailyInterval
|
// 1. Tick_Fires_OnDailyInterval
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the purge actor runs a purge pass on each daily interval tick.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_Fires_OnDailyInterval()
|
public void Tick_Fires_OnDailyInterval()
|
||||||
{
|
{
|
||||||
@@ -216,6 +239,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// 2. Tick_OldPartitions_SwitchedOut
|
// 2. Tick_OldPartitions_SwitchedOut
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tick switches out partitions older than the retention threshold.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_OldPartitions_SwitchedOut()
|
public void Tick_OldPartitions_SwitchedOut()
|
||||||
{
|
{
|
||||||
@@ -247,6 +271,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// 3. Tick_NewerPartitions_Untouched
|
// 3. Tick_NewerPartitions_Untouched
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tick leaves partitions newer than the retention threshold untouched.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_NewerPartitions_Untouched()
|
public void Tick_NewerPartitions_Untouched()
|
||||||
{
|
{
|
||||||
@@ -276,6 +301,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// 4. Tick_PublishesPurgedEvent_WithRowCount
|
// 4. Tick_PublishesPurgedEvent_WithRowCount
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tick publishes a purged event carrying the number of rows removed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_PublishesPurgedEvent_WithRowCount()
|
public void Tick_PublishesPurgedEvent_WithRowCount()
|
||||||
{
|
{
|
||||||
@@ -301,6 +327,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// 5. Tick_SwitchThrows_OtherPartitionsStillProcessed (continue-on-error)
|
// 5. Tick_SwitchThrows_OtherPartitionsStillProcessed (continue-on-error)
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a failed switch-out does not prevent the remaining partitions from being processed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_SwitchThrows_OtherPartitionsStillProcessed()
|
public void Tick_SwitchThrows_OtherPartitionsStillProcessed()
|
||||||
{
|
{
|
||||||
@@ -333,6 +360,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished
|
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a real partition is switched out end to end, its rows are removed and a purged event is published.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
|
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
|
||||||
{
|
{
|
||||||
@@ -401,7 +430,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
Options.Create(auditOptions),
|
Options.Create(auditOptions),
|
||||||
NullLogger<AuditLogPurgeActor>.Instance)));
|
NullLogger<AuditLogPurgeActor>.Instance)));
|
||||||
|
|
||||||
// Fish for the Jan-2026 partition boundary — the only eligible one in this
|
// Fish for the January 2026 partition boundary — the only eligible one in this
|
||||||
// fixture DB. The generous timeout covers the real drop-and-rebuild dance
|
// fixture DB. The generous timeout covers the real drop-and-rebuild dance
|
||||||
// against MSSQL which routinely takes a couple of seconds on a busy dev container.
|
// against MSSQL which routinely takes a couple of seconds on a busy dev container.
|
||||||
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
@@ -436,6 +465,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// 7. Threshold_UsesAuditLogOptionsRetentionDays
|
// 7. Threshold_UsesAuditLogOptionsRetentionDays
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the purge threshold is derived from the configured audit log retention days.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Threshold_UsesAuditLogOptionsRetentionDays()
|
public void Threshold_UsesAuditLogOptionsRetentionDays()
|
||||||
{
|
{
|
||||||
@@ -467,9 +497,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge (M5.5 T3)
|
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a per-channel retention override shorter than the global window triggers a channel purge.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge()
|
public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge()
|
||||||
{
|
{
|
||||||
@@ -515,9 +546,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge (M5.5 T3)
|
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a per-channel retention override equal to or longer than the global window does not trigger a channel purge.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge()
|
public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge()
|
||||||
{
|
{
|
||||||
@@ -561,9 +593,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 10. PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository (Task 4)
|
// 10. PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a purge tick passes the configured maintenance command timeout to the repository.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository()
|
public void PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository()
|
||||||
{
|
{
|
||||||
@@ -608,9 +641,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter (Task 5)
|
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a failing switch-out publishes a purge-failed event and increments the health failure counter.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter()
|
public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Task 4 tests for <see cref="AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes"/> /
|
/// Tests for <see cref="AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes"/> /
|
||||||
/// <see cref="AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout"/>: the maintenance timeout
|
/// <see cref="AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout"/>: the maintenance timeout
|
||||||
/// defaults to 30 minutes and is clamped to a 1-minute floor so a misconfigured 0/negative value
|
/// defaults to 30 minutes and is clamped to a 1-minute floor so a misconfigured 0/negative value
|
||||||
/// cannot yield a zero/negative ADO.NET command timeout (arch-review 04, S2).
|
/// cannot yield a zero/negative ADO.NET command timeout (arch-review 04, S2).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogPurgeOptionsTests
|
public class AuditLogPurgeOptionsTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the maintenance command timeout defaults to 30 minutes.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MaintenanceCommandTimeout_Defaults_To_30_Minutes()
|
public void MaintenanceCommandTimeout_Defaults_To_30_Minutes()
|
||||||
{
|
{
|
||||||
@@ -18,6 +19,9 @@ public class AuditLogPurgeOptionsTests
|
|||||||
Assert.Equal(TimeSpan.FromMinutes(30), options.ResolvedMaintenanceCommandTimeout);
|
Assert.Equal(TimeSpan.FromMinutes(30), options.ResolvedMaintenanceCommandTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the resolved maintenance command timeout is clamped to a minimum of one minute.</summary>
|
||||||
|
/// <param name="configured">The configured timeout in minutes.</param>
|
||||||
|
/// <param name="expectedMinutes">The expected resolved timeout in minutes.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(0, 1)] // clamp floor — a zero config value cannot become a zero timeout
|
[InlineData(0, 1)] // clamp floor — a zero config value cannot become a zero timeout
|
||||||
[InlineData(-5, 1)] // clamp floor — negatives are rejected by ADO.NET
|
[InlineData(-5, 1)] // clamp floor — negatives are rejected by ADO.NET
|
||||||
|
|||||||
+8
@@ -12,6 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogPurgeOptionsValidatorTests
|
public class AuditLogPurgeOptionsValidatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the default options are valid.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultOptions_AreValid()
|
public void DefaultOptions_AreValid()
|
||||||
{
|
{
|
||||||
@@ -19,6 +21,8 @@ public class AuditLogPurgeOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded);
|
Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero interval in hours fails validation.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroIntervalHours_Fails()
|
public void ZeroIntervalHours_Fails()
|
||||||
{
|
{
|
||||||
@@ -29,6 +33,8 @@ public class AuditLogPurgeOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal));
|
f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero channel purge batch size fails validation.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroChannelPurgeBatchSize_Fails()
|
public void ZeroChannelPurgeBatchSize_Fails()
|
||||||
{
|
{
|
||||||
@@ -40,6 +46,8 @@ public class AuditLogPurgeOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal));
|
f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero maintenance command timeout fails validation.</summary>
|
||||||
|
/// <returns>The result.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroMaintenanceCommandTimeout_Fails()
|
public void ZeroMaintenanceCommandTimeout_Fails()
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-4
@@ -11,16 +11,17 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor
|
/// Coverage for the central-side payload-filter redactor
|
||||||
/// failure bridge. M5 wired the SITE bridge
|
/// failure bridge. The SITE bridge
|
||||||
/// (<c>HealthMetricsAuditRedactionFailureCounter</c>) that pushes increments
|
/// (<c>HealthMetricsAuditRedactionFailureCounter</c>) that pushes increments
|
||||||
/// into the site health report; M6 mirrors that with
|
/// into the site health report; the central bridge mirrors that with
|
||||||
/// <see cref="CentralAuditRedactionFailureCounter"/> so the same payload
|
/// <see cref="CentralAuditRedactionFailureCounter"/> so the same payload
|
||||||
/// filter — when it runs on the central writer paths — surfaces failures on
|
/// filter — when it runs on the central writer paths — surfaces failures on
|
||||||
/// the central <see cref="AuditCentralHealthSnapshot"/>.
|
/// the central <see cref="AuditCentralHealthSnapshot"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CentralAuditRedactionFailureCounterTests : TestKit
|
public class CentralAuditRedactionFailureCounterTests : TestKit
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that incrementing the counter routes the failure to the central health snapshot.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Increment_Routes_To_Snapshot()
|
public void Increment_Routes_To_Snapshot()
|
||||||
{
|
{
|
||||||
@@ -34,6 +35,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
|
|||||||
Assert.Equal(3, snapshot.AuditRedactionFailure);
|
Assert.Equal(3, snapshot.AuditRedactionFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that constructing the counter with a null snapshot throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Construction_With_Null_Snapshot_Throws()
|
public void Construction_With_Null_Snapshot_Throws()
|
||||||
{
|
{
|
||||||
@@ -41,6 +43,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
|
|||||||
() => new CentralAuditRedactionFailureCounter(null!));
|
() => new CentralAuditRedactionFailureCounter(null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that registering central maintenance replaces the redaction failure counter with the central implementation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl()
|
public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl()
|
||||||
{
|
{
|
||||||
@@ -72,11 +75,12 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
|
|||||||
Assert.IsType<CentralAuditRedactionFailureCounter>(counter);
|
Assert.IsType<CentralAuditRedactionFailureCounter>(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default redaction failure counter registered by the audit log is a no-op.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp()
|
public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp()
|
||||||
{
|
{
|
||||||
// Sanity check: without AddAuditLogCentralMaintenance the default
|
// Sanity check: without AddAuditLogCentralMaintenance the default
|
||||||
// remains the NoOp from M5 — the central bridge only takes effect
|
// remains the NoOp — the central bridge only takes effect
|
||||||
// when the central-only registration runs.
|
// when the central-only registration runs.
|
||||||
var config = new ConfigurationBuilder()
|
var config = new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
|||||||
+24
-2
@@ -13,7 +13,7 @@ using IAuditInboundCeilingHitsCounter = ZB.MOM.WW.ScadaBridge.AuditLog.Central.I
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle E (M6-T8) regression coverage for the central-side audit-write
|
/// Regression coverage for the central-side audit-write
|
||||||
/// failure counter. <see cref="CentralAuditWriter"/> and
|
/// failure counter. <see cref="CentralAuditWriter"/> and
|
||||||
/// <see cref="AuditLogIngestActor"/> both swallow repository throws (audit
|
/// <see cref="AuditLogIngestActor"/> both swallow repository throws (audit
|
||||||
/// must NEVER abort the user-facing action, alog.md §13) but bump the
|
/// must NEVER abort the user-facing action, alog.md §13) but bump the
|
||||||
@@ -37,33 +37,43 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class ThrowingRepo : IAuditLogRepository
|
private sealed class ThrowingRepo : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||||
throw new InvalidOperationException("simulated repo failure");
|
throw new InvalidOperationException("simulated repo failure");
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||||
}
|
}
|
||||||
@@ -75,10 +85,14 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter
|
private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter
|
||||||
{
|
{
|
||||||
private int _count;
|
private int _count;
|
||||||
|
/// <summary>Gets the number of increments recorded so far.</summary>
|
||||||
public int Count => Volatile.Read(ref _count);
|
public int Count => Volatile.Read(ref _count);
|
||||||
|
/// <inheritdoc />
|
||||||
public void Increment() => Interlocked.Increment(ref _count);
|
public void Increment() => Interlocked.Increment(ref _count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a forced repository failure increments the audit-write failure counter.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Forced_Failure_Increments_Counter()
|
public async Task Forced_Failure_Increments_Counter()
|
||||||
{
|
{
|
||||||
@@ -101,6 +115,8 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
Assert.Equal(1, counter.Count);
|
Assert.Equal(1, counter.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a repository failure inside the ingest actor increments the audit-write failure counter.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AuditLogIngestActor_Failure_Increments_Counter()
|
public async Task AuditLogIngestActor_Failure_Increments_Counter()
|
||||||
{
|
{
|
||||||
@@ -128,6 +144,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
Assert.Equal(batch.Length, counter.Count);
|
Assert.Equal(batch.Length, counter.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the health snapshot aggregates the failure counters together with the stalled-telemetry state.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Snapshot_Aggregates_Counters_And_StalledState()
|
public void Snapshot_Aggregates_Counters_And_StalledState()
|
||||||
{
|
{
|
||||||
@@ -165,6 +182,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
Assert.Equal(1, snapshot.AuditRedactionFailure);
|
Assert.Equal(1, snapshot.AuditRedactionFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a newly constructed health snapshot reports empty counters.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Snapshot_Empty_OnConstruction()
|
public void Snapshot_Empty_OnConstruction()
|
||||||
{
|
{
|
||||||
@@ -178,11 +196,12 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// M5.3 (T7) AuditInboundCeilingHits counter
|
// AuditInboundCeilingHits counter
|
||||||
// AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter.
|
// AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter.
|
||||||
// Incrementing through the interface surface is reflected on the snapshot.
|
// Incrementing through the interface surface is reflected on the snapshot.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the inbound ceiling-hit counter starts at zero.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditInboundCeilingHits_StartsAtZero()
|
public void AuditInboundCeilingHits_StartsAtZero()
|
||||||
{
|
{
|
||||||
@@ -190,6 +209,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
Assert.Equal(0, snapshot.AuditInboundCeilingHits);
|
Assert.Equal(0, snapshot.AuditInboundCeilingHits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that incrementing the ceiling-hit counter through its interface is reflected on the snapshot.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot()
|
public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot()
|
||||||
{
|
{
|
||||||
@@ -203,6 +223,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
Assert.Equal(3, snapshot.AuditInboundCeilingHits);
|
Assert.Equal(3, snapshot.AuditInboundCeilingHits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that concurrent increments of the ceiling-hit counter are not lost.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditInboundCeilingHits_IsThreadSafe()
|
public void AuditInboundCeilingHits_IsThreadSafe()
|
||||||
{
|
{
|
||||||
@@ -217,6 +238,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits);
|
Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the ceiling-hit counter does not affect the other health counters.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditInboundCeilingHits_IsIndependentOfOtherCounters()
|
public void AuditInboundCeilingHits_IsIndependentOfOtherCounters()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
|
/// Unit tests for <see cref="CentralAuditWriter"/>, the
|
||||||
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
||||||
/// The writer is a thin wrapper around
|
/// The writer is a thin wrapper around
|
||||||
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
|
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
|
||||||
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class CentralAuditWriterTests
|
public class CentralAuditWriterTests
|
||||||
{
|
{
|
||||||
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
|
// Canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
|
||||||
private static AuditEvent NewEvent(Guid? eventId = null) =>
|
private static AuditEvent NewEvent(Guid? eventId = null) =>
|
||||||
ScadaBridgeAuditEventFactory.Create(
|
ScadaBridgeAuditEventFactory.Create(
|
||||||
channel: AuditChannel.Notification,
|
channel: AuditChannel.Notification,
|
||||||
@@ -43,6 +43,8 @@ public class CentralAuditWriterTests
|
|||||||
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
|
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that WriteAsync passes the event to the repository's InsertIfNotExistsAsync.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
|
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
|
||||||
{
|
{
|
||||||
@@ -56,6 +58,8 @@ public class CentralAuditWriterTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that WriteAsync stamps the ingested-at UTC time on the event before inserting it.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
|
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
|
||||||
{
|
{
|
||||||
@@ -66,7 +70,7 @@ public class CentralAuditWriterTests
|
|||||||
|
|
||||||
var after = DateTime.UtcNow;
|
var after = DateTime.UtcNow;
|
||||||
await repo.Received(1).InsertIfNotExistsAsync(
|
await repo.Received(1).InsertIfNotExistsAsync(
|
||||||
// C3 (Task 2.5): IngestedAtUtc now rides in DetailsJson on the canonical
|
// IngestedAtUtc now rides in DetailsJson on the canonical
|
||||||
// record — read it back via the decomposed row view.
|
// record — read it back via the decomposed row view.
|
||||||
Arg.Is<AuditEvent>(e =>
|
Arg.Is<AuditEvent>(e =>
|
||||||
e.AsRow().IngestedAtUtc != null &&
|
e.AsRow().IngestedAtUtc != null &&
|
||||||
@@ -75,6 +79,8 @@ public class CentralAuditWriterTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an exception thrown by the repository during WriteAsync does not propagate to the caller.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
|
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
|
||||||
{
|
{
|
||||||
@@ -90,6 +96,8 @@ public class CentralAuditWriterTests
|
|||||||
await writer.WriteAsync(NewEvent());
|
await writer.WriteAsync(NewEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that WriteAsync resolves the repository from a fresh scope on every call.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
|
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
|
||||||
{
|
{
|
||||||
@@ -114,6 +122,7 @@ public class CentralAuditWriterTests
|
|||||||
Assert.NotSame(instances[0], instances[1]);
|
Assert.NotSame(instances[0], instances[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that constructing the writer with a null service provider throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_NullServices_Throws()
|
public void Constructor_NullServices_Throws()
|
||||||
{
|
{
|
||||||
@@ -121,6 +130,7 @@ public class CentralAuditWriterTests
|
|||||||
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
|
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that constructing the writer with a null logger throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_NullLogger_Throws()
|
public void Constructor_NullLogger_Throws()
|
||||||
{
|
{
|
||||||
@@ -129,7 +139,7 @@ public class CentralAuditWriterTests
|
|||||||
() => new CentralAuditWriter(services, null!));
|
() => new CentralAuditWriter(services, null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- SourceNode stamping (Task 12) ----- //
|
// ----- SourceNode stamping ----- //
|
||||||
|
|
||||||
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
|
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
|
||||||
INodeIdentityProvider? nodeIdentity)
|
INodeIdentityProvider? nodeIdentity)
|
||||||
@@ -147,6 +157,8 @@ public class CentralAuditWriterTests
|
|||||||
return (writer, repo);
|
return (writer, repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that WriteAsync stamps the source node from the identity provider when the event has none.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
|
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
|
||||||
{
|
{
|
||||||
@@ -159,6 +171,8 @@ public class CentralAuditWriterTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that WriteAsync preserves a source node supplied by the caller.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
|
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
|
||||||
{
|
{
|
||||||
@@ -172,6 +186,8 @@ public class CentralAuditWriterTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that WriteAsync leaves the source node null when the identity provider returns null.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
|
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
|
||||||
{
|
{
|
||||||
@@ -184,11 +200,13 @@ public class CentralAuditWriterTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that WriteAsync passes the caller's source node through unchanged when no identity provider is injected.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
|
public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
|
||||||
{
|
{
|
||||||
// Locks the back-compat contract for the optional `nodeIdentity = null`
|
// Locks the back-compat contract for the optional `nodeIdentity = null`
|
||||||
// ctor parameter: when no provider is wired (e.g. legacy M4 test
|
// ctor parameter: when no provider is wired (e.g. legacy test
|
||||||
// composition roots), the writer must not stamp — caller value passes
|
// composition roots), the writer must not stamp — caller value passes
|
||||||
// through unmodified. Distinct code path from
|
// through unmodified. Distinct code path from
|
||||||
// "provider supplied, returns null", which the test above covers.
|
// "provider supplied, returns null", which the test above covers.
|
||||||
@@ -201,6 +219,8 @@ public class CentralAuditWriterTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that WriteAsync leaves the source node null when neither the caller nor an identity provider supplies one.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected()
|
public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected()
|
||||||
{
|
{
|
||||||
|
|||||||
+39
-3
@@ -12,7 +12,7 @@ using ProtoPullResponse = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEven
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle (M6) tests for <see cref="GrpcPullAuditEventsClient"/> — the
|
/// Tests for <see cref="GrpcPullAuditEventsClient"/> — the
|
||||||
/// production <see cref="IPullAuditEventsClient"/> that dials a site over gRPC
|
/// production <see cref="IPullAuditEventsClient"/> that dials a site over gRPC
|
||||||
/// and issues the <c>PullAuditEvents</c> unary RPC for the reconciliation loop.
|
/// and issues the <c>PullAuditEvents</c> unary RPC for the reconciliation loop.
|
||||||
/// The real <c>GrpcChannel</c> is replaced by an injected
|
/// The real <c>GrpcChannel</c> is replaced by an injected
|
||||||
@@ -29,7 +29,10 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
private sealed class StaticEnumerator : ISiteEnumerator
|
private sealed class StaticEnumerator : ISiteEnumerator
|
||||||
{
|
{
|
||||||
private readonly IReadOnlyList<SiteEntry> _sites;
|
private readonly IReadOnlyList<SiteEntry> _sites;
|
||||||
|
/// <summary>Initializes the enumerator with the fixed set of sites it will return.</summary>
|
||||||
|
/// <param name="sites">The sites the enumerator returns on every call.</param>
|
||||||
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult(_sites);
|
Task.FromResult(_sites);
|
||||||
}
|
}
|
||||||
@@ -41,8 +44,11 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class FakeInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
|
private sealed class FakeInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the endpoint most recently dialed through this invoker.</summary>
|
||||||
public string? Endpoint { get; private set; }
|
public string? Endpoint { get; private set; }
|
||||||
|
/// <summary>Gets the request most recently passed to this invoker.</summary>
|
||||||
public ProtoPullRequest? Request { get; private set; }
|
public ProtoPullRequest? Request { get; private set; }
|
||||||
|
/// <summary>Gets the number of times this invoker has been called.</summary>
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
private readonly ProtoPullResponse? _response;
|
private readonly ProtoPullResponse? _response;
|
||||||
@@ -54,9 +60,17 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
_throw = toThrow;
|
_throw = toThrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates an invoker that returns the given response.</summary>
|
||||||
|
/// <param name="response">The response returned from every invocation.</param>
|
||||||
|
/// <returns>An invoker that returns the given response.</returns>
|
||||||
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
|
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
|
||||||
|
/// <summary>Creates an invoker that throws the given exception.</summary>
|
||||||
|
/// <param name="ex">The exception thrown from every invocation.</param>
|
||||||
|
/// <returns>An invoker that throws the given exception.</returns>
|
||||||
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
|
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
|
||||||
|
|
||||||
|
// Records the call and returns the canned response or throws the canned exception.
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ProtoPullResponse> InvokeAsync(
|
public Task<ProtoPullResponse> InvokeAsync(
|
||||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -72,17 +86,22 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-endpoint fake invoker (Task 18): routes dials by endpoint string so a
|
/// Per-endpoint fake invoker: routes dials by endpoint string so a
|
||||||
/// NodeA-down / NodeB-up failover can be scripted. Records dialed endpoints.
|
/// NodeA-down / NodeB-up failover can be scripted. Records dialed endpoints.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class PerEndpointInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
|
private sealed class PerEndpointInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
|
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
|
||||||
|
/// <summary>Gets the endpoints dialed so far, in call order.</summary>
|
||||||
public List<string> Dialed { get; } = new();
|
public List<string> Dialed { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Initializes the invoker with the scripted response factory for each endpoint.</summary>
|
||||||
|
/// <param name="byEndpoint">The response factory keyed by endpoint address.</param>
|
||||||
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
|
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
|
||||||
_byEndpoint = byEndpoint;
|
_byEndpoint = byEndpoint;
|
||||||
|
|
||||||
|
// Records the dialed endpoint and returns the response scripted for it.
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ProtoPullResponse> InvokeAsync(
|
public Task<ProtoPullResponse> InvokeAsync(
|
||||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -100,6 +119,8 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
status: AuditStatus.Delivered,
|
status: AuditStatus.Delivered,
|
||||||
sourceSiteId: "site-a"));
|
sourceSiteId: "site-a"));
|
||||||
|
|
||||||
|
/// <summary>Verifies that the pull dials the resolved endpoint and maps the returned events oldest first.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_dials_the_resolved_endpoint_and_maps_events_oldest_first()
|
public async Task PullAsync_dials_the_resolved_endpoint_and_maps_events_oldest_first()
|
||||||
{
|
{
|
||||||
@@ -132,6 +153,8 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
Assert.Equal(newer, result.Events[1].EventId);
|
Assert.Equal(newer, result.Events[1].EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that pulling from a site with no known endpoint returns an empty response.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
|
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
|
||||||
{
|
{
|
||||||
@@ -148,6 +171,9 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
|
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that tolerable transport faults are swallowed and produce an empty response.</summary>
|
||||||
|
/// <param name="code">The gRPC status code the invoker fails with.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(StatusCode.Unavailable)] // connection refused / site offline
|
[InlineData(StatusCode.Unavailable)] // connection refused / site offline
|
||||||
[InlineData(StatusCode.DeadlineExceeded)] // slow site / network blip
|
[InlineData(StatusCode.DeadlineExceeded)] // slow site / network blip
|
||||||
@@ -167,6 +193,8 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that connection-layer faults are swallowed and produce an empty response.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
|
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
|
||||||
{
|
{
|
||||||
@@ -184,6 +212,8 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that unexpected faults are swallowed and produce an empty response.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
|
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
|
||||||
{
|
{
|
||||||
@@ -203,6 +233,8 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unspecified minimum-value cursor does not throw and the site is still dialed.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
|
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
|
||||||
{
|
{
|
||||||
@@ -233,10 +265,12 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the pull fails over to the secondary node when the primary raises a transport fault.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
|
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
|
||||||
{
|
{
|
||||||
// Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the
|
// NodeA (primary) is unreachable, NodeB (fallback) answers — the
|
||||||
// pull must return NodeB's events rather than collapsing to empty.
|
// pull must return NodeB's events rather than collapsing to empty.
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
var fallbackResponse = new ProtoPullResponse { MoreAvailable = false };
|
var fallbackResponse = new ProtoPullResponse { MoreAvailable = false };
|
||||||
@@ -259,6 +293,8 @@ public class GrpcPullAuditEventsClientTests
|
|||||||
Assert.Equal(id, evt.EventId);
|
Assert.Equal(id, evt.EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that no failover is attempted when the site has no fallback endpoint configured.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
|
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for <see cref="GrpcPullSiteCallsClient"/> — the production
|
/// Tests for <see cref="GrpcPullSiteCallsClient"/> — the production
|
||||||
/// <see cref="IPullSiteCallsClient"/> that dials a site over gRPC and issues the
|
/// <see cref="IPullSiteCallsClient"/> that dials a site over gRPC and issues the
|
||||||
/// <c>PullSiteCalls</c> unary RPC for the Site Call Audit (#22) reconciliation
|
/// <c>PullSiteCalls</c> unary RPC for the Site Call Audit reconciliation
|
||||||
/// loop. The real <c>GrpcChannel</c> is replaced by an injected
|
/// loop. The real <c>GrpcChannel</c> is replaced by an injected
|
||||||
/// <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/> seam so the
|
/// <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/> seam so the
|
||||||
/// client's mapping / ordering / SourceSite-restamp / fault-swallowing behaviour
|
/// client's mapping / ordering / SourceSite-restamp / fault-swallowing behaviour
|
||||||
@@ -26,15 +26,21 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
private sealed class StaticEnumerator : ISiteEnumerator
|
private sealed class StaticEnumerator : ISiteEnumerator
|
||||||
{
|
{
|
||||||
private readonly IReadOnlyList<SiteEntry> _sites;
|
private readonly IReadOnlyList<SiteEntry> _sites;
|
||||||
|
/// <summary>Initializes a new instance of the <c>StaticEnumerator</c> class.</summary>
|
||||||
|
/// <param name="sites">The sites.</param>
|
||||||
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult(_sites);
|
Task.FromResult(_sites);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
|
private sealed class FakeInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the endpoint most recently dialed.</summary>
|
||||||
public string? Endpoint { get; private set; }
|
public string? Endpoint { get; private set; }
|
||||||
|
/// <summary>Gets the request most recently received.</summary>
|
||||||
public ProtoPullRequest? Request { get; private set; }
|
public ProtoPullRequest? Request { get; private set; }
|
||||||
|
/// <summary>Gets the number of calls made.</summary>
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
private readonly ProtoPullResponse? _response;
|
private readonly ProtoPullResponse? _response;
|
||||||
@@ -46,9 +52,16 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
_throw = toThrow;
|
_throw = toThrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates an invoker that returns the given response.</summary>
|
||||||
|
/// <param name="response">The response to return.</param>
|
||||||
|
/// <returns>A fake invoker returning <paramref name="response"/>.</returns>
|
||||||
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
|
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
|
||||||
|
/// <summary>Creates an invoker that throws the given exception.</summary>
|
||||||
|
/// <param name="ex">The exception to throw.</param>
|
||||||
|
/// <returns>A fake invoker that throws <paramref name="ex"/>.</returns>
|
||||||
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
|
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ProtoPullResponse> InvokeAsync(
|
public Task<ProtoPullResponse> InvokeAsync(
|
||||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -64,18 +77,22 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-endpoint fake invoker (Task 18): dials are routed by endpoint string so
|
/// Per-endpoint fake invoker: dials are routed by endpoint string so
|
||||||
/// a NodeA-down / NodeB-up failover can be scripted. Records every dialed
|
/// a NodeA-down / NodeB-up failover can be scripted. Records every dialed
|
||||||
/// endpoint in order.
|
/// endpoint in order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class PerEndpointInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
|
private sealed class PerEndpointInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
|
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
|
||||||
|
/// <summary>Gets the endpoints dialed, in order.</summary>
|
||||||
public List<string> Dialed { get; } = new();
|
public List<string> Dialed { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <c>PerEndpointInvoker</c> class.</summary>
|
||||||
|
/// <param name="byEndpoint">The by endpoint.</param>
|
||||||
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
|
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
|
||||||
_byEndpoint = byEndpoint;
|
_byEndpoint = byEndpoint;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ProtoPullResponse> InvokeAsync(
|
public Task<ProtoPullResponse> InvokeAsync(
|
||||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -103,6 +120,8 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
UpdatedAtUtc = Timestamp.FromDateTime(updatedAtUtc),
|
UpdatedAtUtc = Timestamp.FromDateTime(updatedAtUtc),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync dials the resolved endpoint, maps rows oldest first and restamps the source site.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_dials_resolved_endpoint_maps_oldest_first_and_restamps_source_site()
|
public async Task PullAsync_dials_resolved_endpoint_maps_oldest_first_and_restamps_source_site()
|
||||||
{
|
{
|
||||||
@@ -144,6 +163,8 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
Assert.Equal(1, result.SiteCalls[0].RetryCount);
|
Assert.Equal(1, result.SiteCalls[0].RetryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync returns an empty response when the site endpoint is unknown.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
|
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
|
||||||
{
|
{
|
||||||
@@ -160,6 +181,9 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
|
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync turns tolerable transport faults into an empty response.</summary>
|
||||||
|
/// <param name="code">The code.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(StatusCode.Unavailable)]
|
[InlineData(StatusCode.Unavailable)]
|
||||||
[InlineData(StatusCode.DeadlineExceeded)]
|
[InlineData(StatusCode.DeadlineExceeded)]
|
||||||
@@ -178,6 +202,8 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync turns connection-layer faults into an empty response.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
|
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
|
||||||
{
|
{
|
||||||
@@ -193,6 +219,8 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync turns unexpected faults into an empty response.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
|
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
|
||||||
{
|
{
|
||||||
@@ -208,6 +236,8 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync skips a poison row and still returns the good rows.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_skips_poison_row_and_returns_the_good_rows()
|
public async Task PullAsync_skips_poison_row_and_returns_the_good_rows()
|
||||||
{
|
{
|
||||||
@@ -244,6 +274,8 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync with a MinValue unspecified-kind cursor does not throw and still dials the site.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
|
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
|
||||||
{
|
{
|
||||||
@@ -271,10 +303,12 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
Assert.False(result.MoreAvailable);
|
Assert.False(result.MoreAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync fails over to node B when the primary raises a transport fault.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
|
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
|
||||||
{
|
{
|
||||||
// Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the
|
// NodeA (primary) is unreachable, NodeB (fallback) answers — the
|
||||||
// pull must return NodeB's rows rather than collapsing to empty.
|
// pull must return NodeB's rows rather than collapsing to empty.
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
var fallbackResponse = new ProtoPullResponse();
|
var fallbackResponse = new ProtoPullResponse();
|
||||||
@@ -299,6 +333,8 @@ public class GrpcPullSiteCallsClientTests
|
|||||||
Assert.Equal("site-a", row.SourceSite);
|
Assert.Equal("site-a", row.SourceSite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that PullAsync does not fail over when no fallback is configured.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
|
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
|
||||||
{
|
{
|
||||||
|
|||||||
+82
-8
@@ -18,19 +18,21 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle B (M6-T3) tests for <see cref="SiteAuditReconciliationActor"/>. Most
|
/// Tests for <see cref="SiteAuditReconciliationActor"/>. Most
|
||||||
/// tests substitute the <see cref="IAuditLogRepository"/> with an in-memory
|
/// tests substitute the <see cref="IAuditLogRepository"/> with an in-memory
|
||||||
/// recording stub so the actor's tick / cursor / stalled state machinery can
|
/// recording stub so the actor's tick / cursor / stalled state machinery can
|
||||||
/// be exercised in milliseconds without an MSSQL container. The duplicate /
|
/// be exercised in milliseconds without an MSSQL container. The duplicate /
|
||||||
/// idempotency assertion uses the real <see cref="AuditLogRepository"/> against
|
/// idempotency assertion uses the real <see cref="AuditLogRepository"/> against
|
||||||
/// the <see cref="MsSqlMigrationFixture"/> so we verify InsertIfNotExistsAsync
|
/// the <see cref="MsSqlMigrationFixture"/> so we verify InsertIfNotExistsAsync
|
||||||
/// actually swallows duplicate-key collisions (the M2 Bundle A race-fix the
|
/// actually swallows duplicate-key collisions (the race fix the
|
||||||
/// reconciliation puller depends on).
|
/// reconciliation puller depends on).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||||
public SiteAuditReconciliationActorTests(MsSqlMigrationFixture fixture)
|
public SiteAuditReconciliationActorTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -68,13 +70,17 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class RecordingRepo : IAuditLogRepository
|
private sealed class RecordingRepo : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
|
/// <summary>Gets the audit events recorded by this stub.</summary>
|
||||||
public List<AuditEvent> Inserted { get; } = new();
|
public List<AuditEvent> Inserted { get; } = new();
|
||||||
private readonly HashSet<Guid> _seen = new();
|
private readonly HashSet<Guid> _seen = new();
|
||||||
|
/// <summary>Gets the number of times <c>InsertIfNotExistsAsync</c> was called.</summary>
|
||||||
public int InsertCallCount { get; private set; }
|
public int InsertCallCount { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
InsertCallCount++;
|
InsertCallCount++;
|
||||||
@@ -85,33 +91,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||||
}
|
}
|
||||||
@@ -120,24 +134,30 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
/// Recording stub whose <see cref="InsertIfNotExistsAsync"/> throws for ONE
|
/// Recording stub whose <see cref="InsertIfNotExistsAsync"/> throws for ONE
|
||||||
/// poison <see cref="AuditEvent.EventId"/> (every attempt) but records every
|
/// poison <see cref="AuditEvent.EventId"/> (every attempt) but records every
|
||||||
/// other row — including the synthetic ReconciliationAbandoned row the actor
|
/// other row — including the synthetic ReconciliationAbandoned row the actor
|
||||||
/// emits once it gives up on the poison row (Task 17).
|
/// emits once it gives up on the poison row.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class PoisonInsertRepo : IAuditLogRepository
|
private sealed class PoisonInsertRepo : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
private readonly Guid _poisonId;
|
private readonly Guid _poisonId;
|
||||||
private readonly Exception _fault;
|
private readonly Exception _fault;
|
||||||
|
/// <summary>Gets the audit events recorded by this stub.</summary>
|
||||||
public List<AuditEvent> Inserted { get; } = new();
|
public List<AuditEvent> Inserted { get; } = new();
|
||||||
private readonly HashSet<Guid> _seen = new();
|
private readonly HashSet<Guid> _seen = new();
|
||||||
|
|
||||||
|
/// <summary>Initializes the repository so that inserting the given event id always throws the given fault.</summary>
|
||||||
|
/// <param name="poisonId">The event id whose insert always fails.</param>
|
||||||
|
/// <param name="fault">The exception thrown on every attempt to insert the poison row.</param>
|
||||||
public PoisonInsertRepo(Guid poisonId, Exception fault)
|
public PoisonInsertRepo(Guid poisonId, Exception fault)
|
||||||
{
|
{
|
||||||
_poisonId = poisonId;
|
_poisonId = poisonId;
|
||||||
_fault = fault;
|
_fault = fault;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (evt.EventId == _poisonId)
|
if (evt.EventId == _poisonId)
|
||||||
@@ -151,33 +171,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||||
}
|
}
|
||||||
@@ -191,19 +219,22 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
/// row that also never inserts but changes EventId every pull — i.e. it never
|
/// row that also never inserts but changes EventId every pull — i.e. it never
|
||||||
/// individually reaches <c>MaxPermanentInsertAttempts</c>, so it keeps
|
/// individually reaches <c>MaxPermanentInsertAttempts</c>, so it keeps
|
||||||
/// <c>hasUnresolvedFailure</c> true and holds the cursor at <c>since</c> forever
|
/// <c>hasUnresolvedFailure</c> true and holds the cursor at <c>since</c> forever
|
||||||
/// (P5-6's defect trigger). Dedupes inserted rows on <see cref="AuditEvent.EventId"/>
|
/// (the defect trigger). Dedupes inserted rows on <see cref="AuditEvent.EventId"/>
|
||||||
/// exactly like the real <c>InsertIfNotExistsAsync</c>, so a test asserting on
|
/// exactly like the real <c>InsertIfNotExistsAsync</c>, so a test asserting on
|
||||||
/// <see cref="Inserted"/>'s count is exercising the same idempotency contract
|
/// <see cref="Inserted"/>'s count is exercising the same idempotency contract
|
||||||
/// production relies on.
|
/// production relies on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository
|
private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
|
/// <summary>Gets the audit events recorded by this stub.</summary>
|
||||||
public List<AuditEvent> Inserted { get; } = new();
|
public List<AuditEvent> Inserted { get; } = new();
|
||||||
private readonly HashSet<Guid> _seen = new();
|
private readonly HashSet<Guid> _seen = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!evt.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal))
|
if (!evt.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal))
|
||||||
@@ -217,33 +248,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||||
}
|
}
|
||||||
@@ -254,7 +293,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
private sealed class StaticEnumerator : ISiteEnumerator
|
private sealed class StaticEnumerator : ISiteEnumerator
|
||||||
{
|
{
|
||||||
private readonly IReadOnlyList<SiteEntry> _sites;
|
private readonly IReadOnlyList<SiteEntry> _sites;
|
||||||
|
/// <summary>Initializes the enumerator with a fixed set of sites.</summary>
|
||||||
|
/// <param name="sites">The sites returned on every enumeration.</param>
|
||||||
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult(_sites);
|
Task.FromResult(_sites);
|
||||||
}
|
}
|
||||||
@@ -266,22 +308,32 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class ScriptedPullClient : IPullAuditEventsClient
|
private sealed class ScriptedPullClient : IPullAuditEventsClient
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the recorded pull calls in the order they were made.</summary>
|
||||||
public List<(string SiteId, DateTime SinceUtc, string? AfterId, int BatchSize)> Calls { get; } = new();
|
public List<(string SiteId, DateTime SinceUtc, string? AfterId, int BatchSize)> Calls { get; } = new();
|
||||||
private readonly Dictionary<string, Queue<PullAuditEventsResponse>> _scripted = new();
|
private readonly Dictionary<string, Queue<PullAuditEventsResponse>> _scripted = new();
|
||||||
private readonly Dictionary<string, Exception> _throwOnSite = new();
|
private readonly Dictionary<string, Exception> _throwOnSite = new();
|
||||||
|
|
||||||
|
/// <summary>Queues scripted responses for the given site.</summary>
|
||||||
|
/// <param name="siteId">The site whose pulls receive the scripted responses.</param>
|
||||||
|
/// <param name="responses">The responses returned in order on successive pulls.</param>
|
||||||
|
/// <returns>This client, for chaining.</returns>
|
||||||
public ScriptedPullClient Script(string siteId, params PullAuditEventsResponse[] responses)
|
public ScriptedPullClient Script(string siteId, params PullAuditEventsResponse[] responses)
|
||||||
{
|
{
|
||||||
_scripted[siteId] = new Queue<PullAuditEventsResponse>(responses);
|
_scripted[siteId] = new Queue<PullAuditEventsResponse>(responses);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes every pull for the given site throw the given exception.</summary>
|
||||||
|
/// <param name="siteId">The site whose pulls fail.</param>
|
||||||
|
/// <param name="ex">The exception to throw.</param>
|
||||||
|
/// <returns>This client, for chaining.</returns>
|
||||||
public ScriptedPullClient ThrowFor(string siteId, Exception ex)
|
public ScriptedPullClient ThrowFor(string siteId, Exception ex)
|
||||||
{
|
{
|
||||||
_throwOnSite[siteId] = ex;
|
_throwOnSite[siteId] = ex;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<PullAuditEventsResponse> PullAsync(
|
public Task<PullAuditEventsResponse> PullAsync(
|
||||||
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -373,6 +425,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// 1. Timer_Fires_OnConfiguredInterval
|
// 1. Timer_Fires_OnConfiguredInterval
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the reconciliation timer fires on the configured interval.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Timer_Fires_OnConfiguredInterval()
|
public void Timer_Fires_OnConfiguredInterval()
|
||||||
{
|
{
|
||||||
@@ -400,6 +453,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// 2. Tick_PullsFromEachKnownSite
|
// 2. Tick_PullsFromEachKnownSite
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that each tick pulls from every known site.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_PullsFromEachKnownSite()
|
public void Tick_PullsFromEachKnownSite()
|
||||||
{
|
{
|
||||||
@@ -426,6 +480,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync
|
// 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that pulled events are ingested through the repository insert-if-not-exists call.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_IngestEvents_ViaInsertIfNotExistsAsync()
|
public void Tick_IngestEvents_ViaInsertIfNotExistsAsync()
|
||||||
{
|
{
|
||||||
@@ -456,6 +511,8 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||||
.UseSqlServer(_fixture.ConnectionString).Options);
|
.UseSqlServer(_fixture.ConnectionString).Options);
|
||||||
|
|
||||||
|
/// <summary>Verifies that duplicate events are not inserted twice.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Tick_Duplicates_NotDoubleInserted()
|
public async Task Tick_Duplicates_NotDoubleInserted()
|
||||||
{
|
{
|
||||||
@@ -509,6 +566,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// 5. Cursor_Advances_ToMaxOccurredAtUtc
|
// 5. Cursor_Advances_ToMaxOccurredAtUtc
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the cursor advances to the maximum occurred-at timestamp of the pulled events.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Cursor_Advances_ToMaxOccurredAtUtc()
|
public void Cursor_Advances_ToMaxOccurredAtUtc()
|
||||||
{
|
{
|
||||||
@@ -545,13 +603,14 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
|
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
|
||||||
Assert.Equal(t3, client.Calls[1].SinceUtc);
|
Assert.Equal(t3, client.Calls[1].SinceUtc);
|
||||||
|
|
||||||
// The composite half travels too (arch-review phase-2 residual #5): the first pull has
|
// The composite half travels too: the first pull has
|
||||||
// no cursor at all, the second carries the id of the row at t3 so the site can retire
|
// no cursor at all, the second carries the id of the row at t3 so the site can retire
|
||||||
// it exactly instead of leaving the boundary instant permanently servable.
|
// it exactly instead of leaving the boundary instant permanently servable.
|
||||||
Assert.Null(client.Calls[0].AfterId);
|
Assert.Null(client.Calls[0].AfterId);
|
||||||
Assert.Equal(e3.EventId.ToString(), client.Calls[1].AfterId);
|
Assert.Equal(e3.EventId.ToString(), client.Calls[1].AfterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the cursor advances on the event id tiebreak when every row shares one instant.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Cursor_AdvancesOnTheIdTiebreak_WhenEveryRowSharesOneInstant()
|
public void Cursor_AdvancesOnTheIdTiebreak_WhenEveryRowSharesOneInstant()
|
||||||
{
|
{
|
||||||
@@ -583,6 +642,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Assert.Equal(high.EventId.ToString(), client.Calls[1].AfterId);
|
Assert.Equal(high.EventId.ToString(), client.Calls[1].AfterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that both cursor halves are held back while a row is still being retried.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BothCursorHalves_AreHeldBack_WhileARowIsStillBeingRetried()
|
public void BothCursorHalves_AreHeldBack_WhileARowIsStillBeingRetried()
|
||||||
{
|
{
|
||||||
@@ -612,41 +672,51 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
/// <summary>Repository whose every insert throws, so the retry hold-back path is taken.</summary>
|
/// <summary>Repository whose every insert throws, so the retry hold-back path is taken.</summary>
|
||||||
private sealed class AlwaysThrowingRepo : IAuditLogRepository
|
private sealed class AlwaysThrowingRepo : IAuditLogRepository
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||||||
throw new InvalidOperationException("central insert failed");
|
throw new InvalidOperationException("central insert failed");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> SwitchOutPartitionAsync(
|
public Task<long> SwitchOutPartitionAsync(
|
||||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null,
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null,
|
||||||
CancellationToken ct = default, int maxRows = 50_000)
|
CancellationToken ct = default, int maxRows = 50_000)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default)
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default)
|
DateTime threshold, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
@@ -655,6 +725,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// 6. Tick_OneSiteThrows_OtherSitesStillProcessed
|
// 6. Tick_OneSiteThrows_OtherSitesStillProcessed
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a failure pulling from one site does not stop the other sites from being processed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tick_OneSiteThrows_OtherSitesStillProcessed()
|
public void Tick_OneSiteThrows_OtherSitesStillProcessed()
|
||||||
{
|
{
|
||||||
@@ -686,6 +757,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// 7. StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue
|
// 7. StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that two consecutive non-draining cycles publish a stalled state of true.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue()
|
public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue()
|
||||||
{
|
{
|
||||||
@@ -717,6 +789,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// 8. StalledDetection_DrainingCycle_PublishesStalledFalse
|
// 8. StalledDetection_DrainingCycle_PublishesStalledFalse
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a draining cycle publishes a stalled state of false.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StalledDetection_DrainingCycle_PublishesStalledFalse()
|
public void StalledDetection_DrainingCycle_PublishesStalledFalse()
|
||||||
{
|
{
|
||||||
@@ -750,11 +823,12 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 9. Task 17: a permanently-abandoned row leaves a durable
|
// 9. A permanently-abandoned row leaves a durable
|
||||||
// ReconciliationAbandoned audit record (not just a Critical log line),
|
// ReconciliationAbandoned audit record (not just a Critical log line),
|
||||||
// and the cursor advances past it.
|
// and the cursor advances past it.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a permanently abandoned row emits a ReconciliationAbandoned row and advances the cursor.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PermanentAbandonment_EmitsReconciliationAbandonedRow_AndAdvancesCursor()
|
public void PermanentAbandonment_EmitsReconciliationAbandonedRow_AndAdvancesCursor()
|
||||||
{
|
{
|
||||||
@@ -804,11 +878,11 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 10. P5-6: deterministic synthetic ReconciliationAbandoned EventId
|
// 10. Deterministic synthetic ReconciliationAbandoned EventId
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reproduces the P5-6 defect end to end: a permanently-poisoned row sits
|
/// Reproduces the rotating-poison defect end to end: a permanently-poisoned row sits
|
||||||
/// in a batch alongside a co-batch row that ALSO fails on every pull but
|
/// in a batch alongside a co-batch row that ALSO fails on every pull but
|
||||||
/// carries a fresh <see cref="AuditEvent.EventId"/> each time (modelling a
|
/// carries a fresh <see cref="AuditEvent.EventId"/> each time (modelling a
|
||||||
/// site that keeps producing new events with transient insert failures).
|
/// site that keeps producing new events with transient insert failures).
|
||||||
|
|||||||
+7
@@ -12,6 +12,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteAuditReconciliationOptionsValidatorTests
|
public class SiteAuditReconciliationOptionsValidatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the batch size is valid only up to the RPC ceiling.</summary>
|
||||||
|
/// <param name="size">The batch size under test.</param>
|
||||||
|
/// <param name="valid">Whether the batch size is expected to validate.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(1, true)]
|
[InlineData(1, true)]
|
||||||
[InlineData(2000, true)]
|
[InlineData(2000, true)]
|
||||||
@@ -21,6 +24,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
|
|||||||
=> Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null,
|
=> Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null,
|
||||||
new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded);
|
new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded);
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default options pass validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultOptions_AreValid()
|
public void DefaultOptions_AreValid()
|
||||||
{
|
{
|
||||||
@@ -28,6 +32,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded);
|
Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero reconciliation interval fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroReconciliationInterval_Fails()
|
public void ZeroReconciliationInterval_Fails()
|
||||||
{
|
{
|
||||||
@@ -39,6 +44,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal));
|
f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero batch size fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroBatchSize_Fails()
|
public void ZeroBatchSize_Fails()
|
||||||
{
|
{
|
||||||
@@ -50,6 +56,7 @@ public class SiteAuditReconciliationOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal));
|
f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero stalled-cycle threshold fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroStalledCycles_Fails()
|
public void ZeroStalledCycles_Fails()
|
||||||
{
|
{
|
||||||
|
|||||||
+7
-1
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle E (M6-T7) tests for <see cref="SiteAuditTelemetryStalledTracker"/>.
|
/// Tests for <see cref="SiteAuditTelemetryStalledTracker"/>.
|
||||||
/// The tracker subscribes to the actor system's EventStream for
|
/// The tracker subscribes to the actor system's EventStream for
|
||||||
/// <see cref="SiteAuditTelemetryStalledChanged"/> publications and maintains a
|
/// <see cref="SiteAuditTelemetryStalledChanged"/> publications and maintains a
|
||||||
/// per-site latch the central health surface can read. Since reconciliation is
|
/// per-site latch the central health surface can read. Since reconciliation is
|
||||||
@@ -36,6 +36,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
|||||||
interval: TimeSpan.FromMilliseconds(20));
|
interval: TimeSpan.FromMilliseconds(20));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a newly created tracker reports an empty snapshot.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Initial_Snapshot_IsEmpty()
|
public void Initial_Snapshot_IsEmpty()
|
||||||
{
|
{
|
||||||
@@ -46,6 +47,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
|||||||
Assert.Empty(snapshot);
|
Assert.Empty(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a stalled event makes the tracker report the site as stalled.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StalledTrue_Event_TrackerReports_Stalled()
|
public void StalledTrue_Event_TrackerReports_Stalled()
|
||||||
{
|
{
|
||||||
@@ -57,6 +59,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
|||||||
Assert.True(snapshot["siteA"]);
|
Assert.True(snapshot["siteA"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a not-stalled event makes the tracker report the site as not stalled.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StalledFalse_Event_TrackerReports_NotStalled()
|
public void StalledFalse_Event_TrackerReports_NotStalled()
|
||||||
{
|
{
|
||||||
@@ -72,6 +75,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
|||||||
Assert.False(snapshot["siteA"]);
|
Assert.False(snapshot["siteA"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that multiple sites are tracked independently of one another.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Multiple_Sites_Tracked_Independently()
|
public void Multiple_Sites_Tracked_Independently()
|
||||||
{
|
{
|
||||||
@@ -88,6 +92,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
|||||||
Assert.True(snapshot["siteC"]);
|
Assert.True(snapshot["siteC"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that constructing the tracker with a null actor system throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_With_Null_ActorSystem_Throws()
|
public void Constructor_With_Null_ActorSystem_Throws()
|
||||||
{
|
{
|
||||||
@@ -95,6 +100,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit
|
|||||||
() => new SiteAuditTelemetryStalledTracker((ActorSystem)null!));
|
() => new SiteAuditTelemetryStalledTracker((ActorSystem)null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that disposing the tracker unsubscribes it from the event stream.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dispose_Unsubscribes_From_EventStream()
|
public void Dispose_Unsubscribes_From_EventStream()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ public class SiteEnumeratorTests
|
|||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that sites with a NodeA address are projected and sites with a blank address are skipped.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnumerateAsync_ProjectsSitesWithNodeAAddress_AndSkipsBlankOnes()
|
public async Task EnumerateAsync_ProjectsSitesWithNodeAAddress_AndSkipsBlankOnes()
|
||||||
{
|
{
|
||||||
@@ -60,6 +62,8 @@ public class SiteEnumeratorTests
|
|||||||
Assert.Equal("http://site-a:8083", entry.GrpcEndpoint);
|
Assert.Equal("http://site-a:8083", entry.GrpcEndpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a site with a null NodeA address is skipped.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnumerateAsync_SkipsNullNodeAAddress()
|
public async Task EnumerateAsync_SkipsNullNodeAAddress()
|
||||||
{
|
{
|
||||||
@@ -76,10 +80,12 @@ public class SiteEnumeratorTests
|
|||||||
Assert.Empty(result);
|
Assert.Empty(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a site with distinct NodeA and NodeB addresses projects NodeB as the fallback endpoint.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnumerateAsync_BothAddresses_ProjectsNodeBAsFallback()
|
public async Task EnumerateAsync_BothAddresses_ProjectsNodeBAsFallback()
|
||||||
{
|
{
|
||||||
// Task 18: a site with both NodeA and a DISTINCT NodeB yields
|
// A site with both NodeA and a DISTINCT NodeB yields
|
||||||
// SiteEntry(primary=NodeA, FallbackGrpcEndpoint=NodeB).
|
// SiteEntry(primary=NodeA, FallbackGrpcEndpoint=NodeB).
|
||||||
var repository = Substitute.For<ISiteRepository>();
|
var repository = Substitute.For<ISiteRepository>();
|
||||||
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
|
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
|
||||||
@@ -95,10 +101,12 @@ public class SiteEnumeratorTests
|
|||||||
Assert.Equal("http://site-b:8083", entry.FallbackGrpcEndpoint);
|
Assert.Equal("http://site-b:8083", entry.FallbackGrpcEndpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a site with only a NodeB address is not skipped and has no fallback endpoint.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnumerateAsync_OnlyNodeB_IsNotSkipped_AndHasNoFallback()
|
public async Task EnumerateAsync_OnlyNodeB_IsNotSkipped_AndHasNoFallback()
|
||||||
{
|
{
|
||||||
// Task 18: a site with a blank NodeA but a valid NodeB is no longer
|
// A site with a blank NodeA but a valid NodeB is no longer
|
||||||
// skipped — NodeB becomes the primary and there is no separate fallback.
|
// skipped — NodeB becomes the primary and there is no separate fallback.
|
||||||
var repository = Substitute.For<ISiteRepository>();
|
var repository = Substitute.For<ISiteRepository>();
|
||||||
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
|
repository.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<SiteEntity>
|
||||||
@@ -114,6 +122,8 @@ public class SiteEnumeratorTests
|
|||||||
Assert.Null(entry.FallbackGrpcEndpoint);
|
Assert.Null(entry.FallbackGrpcEndpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that enumeration returns an empty list when there are no sites.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnumerateAsync_ReturnsEmpty_WhenNoSites()
|
public async Task EnumerateAsync_ReturnsEmpty_WhenNoSites()
|
||||||
{
|
{
|
||||||
|
|||||||
+25
-6
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle D (M5-T8) tests for hot-reloadable <see cref="AuditLogOptions"/>
|
/// Tests for hot-reloadable <see cref="AuditLogOptions"/>
|
||||||
/// binding. The first test pins the JSON-realistic binding shape end-to-end
|
/// binding. The first test pins the JSON-realistic binding shape end-to-end
|
||||||
/// (scalars, lists, per-target overrides) so accidental drift in the section
|
/// (scalars, lists, per-target overrides) so accidental drift in the section
|
||||||
/// layout breaks the build. The second test exercises the live hot-reload
|
/// layout breaks the build. The second test exercises the live hot-reload
|
||||||
@@ -23,13 +23,14 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
|||||||
/// flowing through without a restart.
|
/// flowing through without a restart.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Distinct from <see cref="AuditLogOptionsTests"/> (M1-T9) which covered
|
/// Distinct from <see cref="AuditLogOptionsTests"/> which covered
|
||||||
/// section binding + validator failures via single-key in-memory config — those
|
/// section binding + validator failures via single-key in-memory config — those
|
||||||
/// tests exist; these add (a) end-to-end binding from a realistic JSON literal
|
/// tests exist; these add (a) end-to-end binding from a realistic JSON literal
|
||||||
/// and (b) the hot-reload behavioural contract the M5-T8 spec calls out.
|
/// and (b) the hot-reload behavioural contract the spec calls out.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class AuditLogOptionsBindingTests
|
public class AuditLogOptionsBindingTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the AuditLog configuration section binds every field.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditLog_Section_Binds_AllFields()
|
public void AuditLog_Section_Binds_AllFields()
|
||||||
{
|
{
|
||||||
@@ -71,7 +72,7 @@ public class AuditLogOptionsBindingTests
|
|||||||
|
|
||||||
// HeaderRedactList: the Microsoft.Extensions.Configuration list binder
|
// HeaderRedactList: the Microsoft.Extensions.Configuration list binder
|
||||||
// APPENDS to the default list, so we assert containment rather than
|
// APPENDS to the default list, so we assert containment rather than
|
||||||
// exact equality (see M1-T9 AuditLogOptionsTests for the rationale).
|
// exact equality (see AuditLogOptionsTests for the rationale).
|
||||||
Assert.Contains("Authorization", opts.HeaderRedactList);
|
Assert.Contains("Authorization", opts.HeaderRedactList);
|
||||||
Assert.Contains("Custom-Token", opts.HeaderRedactList);
|
Assert.Contains("Custom-Token", opts.HeaderRedactList);
|
||||||
|
|
||||||
@@ -90,10 +91,11 @@ public class AuditLogOptionsBindingTests
|
|||||||
Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching);
|
Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the purge options bind from the documented section and keys.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PurgeOptions_Bind_FromDocumentedSectionAndKeys()
|
public void PurgeOptions_Bind_FromDocumentedSectionAndKeys()
|
||||||
{
|
{
|
||||||
// AuditLog-013: the design doc (Component-AuditLog.md §Configuration)
|
// The design doc (Component-AuditLog.md §Configuration)
|
||||||
// documents the purge tuning as the nested `AuditLog:Purge` section with
|
// documents the purge tuning as the nested `AuditLog:Purge` section with
|
||||||
// keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the
|
// keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the
|
||||||
// code binds from EXACTLY that shape — the section path the production
|
// code binds from EXACTLY that shape — the section path the production
|
||||||
@@ -139,6 +141,7 @@ public class AuditLogOptionsBindingTests
|
|||||||
Assert.Equal(1000, opts.ChannelPurgeBatchSize);
|
Assert.Equal(1000, opts.ChannelPurgeBatchSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the filter behavior updates when the configuration reloads.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Filter_Behavior_Updates_OnConfigReload()
|
public void Filter_Behavior_Updates_OnConfigReload()
|
||||||
{
|
{
|
||||||
@@ -165,7 +168,7 @@ public class AuditLogOptionsBindingTests
|
|||||||
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);
|
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);
|
||||||
|
|
||||||
// Reload: cap raised to 16384 — next event must NOT truncate. This is
|
// Reload: cap raised to 16384 — next event must NOT truncate. This is
|
||||||
// the M5-T8 contract: the filter sees the new value on the very next
|
// the hot-reload contract: the filter sees the new value on the very next
|
||||||
// Apply, without process restart.
|
// Apply, without process restart.
|
||||||
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
|
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ public class AuditLogOptionsBindingTests
|
|||||||
Assert.Equal(body, resultAfter.AsRow().RequestSummary);
|
Assert.Equal(body, resultAfter.AsRow().RequestSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the filter picks up a new body redactor when the configuration reloads.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Filter_PicksUp_NewBodyRedactor_OnConfigReload()
|
public void Filter_PicksUp_NewBodyRedactor_OnConfigReload()
|
||||||
{
|
{
|
||||||
@@ -219,12 +223,21 @@ public class AuditLogOptionsBindingTests
|
|||||||
private T _current;
|
private T _current;
|
||||||
private readonly List<Action<T, string?>> _listeners = new();
|
private readonly List<Action<T, string?>> _listeners = new();
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <c>TestOptionsMonitor</c> class.</summary>
|
||||||
|
/// <param name="initial">The initial.</param>
|
||||||
public TestOptionsMonitor(T initial) => _current = initial;
|
public TestOptionsMonitor(T initial) => _current = initial;
|
||||||
|
|
||||||
|
/// <summary>Gets the current value value.</summary>
|
||||||
public T CurrentValue => _current;
|
public T CurrentValue => _current;
|
||||||
|
|
||||||
|
/// <summary>Returns the current options value regardless of the name.</summary>
|
||||||
|
/// <param name="name">The options name; ignored.</param>
|
||||||
|
/// <returns>The current options value.</returns>
|
||||||
public T Get(string? name) => _current;
|
public T Get(string? name) => _current;
|
||||||
|
|
||||||
|
/// <summary>Registers a listener to be notified when the options change.</summary>
|
||||||
|
/// <param name="listener">The change callback.</param>
|
||||||
|
/// <returns>A disposable that removes the listener, or null when none is tracked.</returns>
|
||||||
public IDisposable? OnChange(Action<T, string?> listener)
|
public IDisposable? OnChange(Action<T, string?> listener)
|
||||||
{
|
{
|
||||||
lock (_listeners)
|
lock (_listeners)
|
||||||
@@ -234,6 +247,8 @@ public class AuditLogOptionsBindingTests
|
|||||||
return new Unsubscribe(_listeners, listener);
|
return new Unsubscribe(_listeners, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces the current value and notifies registered listeners.</summary>
|
||||||
|
/// <param name="value">The value.</param>
|
||||||
public void Set(T value)
|
public void Set(T value)
|
||||||
{
|
{
|
||||||
_current = value;
|
_current = value;
|
||||||
@@ -252,11 +267,15 @@ public class AuditLogOptionsBindingTests
|
|||||||
{
|
{
|
||||||
private readonly List<Action<T, string?>> _listeners;
|
private readonly List<Action<T, string?>> _listeners;
|
||||||
private readonly Action<T, string?> _listener;
|
private readonly Action<T, string?> _listener;
|
||||||
|
/// <summary>Initializes a new instance of the <c>Unsubscribe</c> class.</summary>
|
||||||
|
/// <param name="listeners">The listeners.</param>
|
||||||
|
/// <param name="listener">The listener.</param>
|
||||||
public Unsubscribe(List<Action<T, string?>> listeners, Action<T, string?> listener)
|
public Unsubscribe(List<Action<T, string?>> listeners, Action<T, string?> listener)
|
||||||
{
|
{
|
||||||
_listeners = listeners;
|
_listeners = listeners;
|
||||||
_listener = listener;
|
_listener = listener;
|
||||||
}
|
}
|
||||||
|
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
lock (_listeners)
|
lock (_listeners)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Task 9 (Bundle E): <see cref="AuditLogOptions"/> binding + validator
|
/// Tests for <see cref="AuditLogOptions"/> binding and validator
|
||||||
/// behavior. The validator enforces invariants used by M2+ writers
|
/// behavior. The validator enforces invariants used by the audit writers
|
||||||
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
|
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
|
||||||
/// <c>DefaultCapBytes > 0</c>, <c>ErrorCapBytes >= DefaultCapBytes</c>,
|
/// <c>DefaultCapBytes > 0</c>, <c>ErrorCapBytes >= DefaultCapBytes</c>,
|
||||||
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
|
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
|
||||||
@@ -25,6 +25,7 @@ public class AuditLogOptionsTests
|
|||||||
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
|
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a fully populated configuration section binds every scalar field onto the options.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidBinding_PopulatesAllScalarFields()
|
public void ValidBinding_PopulatesAllScalarFields()
|
||||||
{
|
{
|
||||||
@@ -40,6 +41,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.Equal(180, opts.RetentionDays);
|
Assert.Equal(180, opts.RetentionDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an empty configuration section yields reasonable default option values.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultsAreReasonable_WhenSectionEmpty()
|
public void DefaultsAreReasonable_WhenSectionEmpty()
|
||||||
{
|
{
|
||||||
@@ -56,6 +58,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.Empty(opts.PerTargetOverrides);
|
Assert.Empty(opts.PerTargetOverrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that header redaction entries bound from configuration are appended to the default list.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
|
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
|
||||||
{
|
{
|
||||||
@@ -77,6 +80,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that per-target override settings bind from configuration.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PerTargetOverrides_BindsFromConfig()
|
public void PerTargetOverrides_BindsFromConfig()
|
||||||
{
|
{
|
||||||
@@ -93,6 +97,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
|
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an invalid default payload cap fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InvalidDefaultCapBytes_FailsValidation()
|
public void InvalidDefaultCapBytes_FailsValidation()
|
||||||
{
|
{
|
||||||
@@ -104,6 +109,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.Contains("DefaultCapBytes", result.FailureMessage);
|
Assert.Contains("DefaultCapBytes", result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an invalid error payload cap fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InvalidErrorCapBytes_FailsValidation()
|
public void InvalidErrorCapBytes_FailsValidation()
|
||||||
{
|
{
|
||||||
@@ -115,6 +121,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.Contains("ErrorCapBytes", result.FailureMessage);
|
Assert.Contains("ErrorCapBytes", result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retention period below the minimum fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RetentionDaysBelowMinimum_FailsValidation()
|
public void RetentionDaysBelowMinimum_FailsValidation()
|
||||||
{
|
{
|
||||||
@@ -126,6 +133,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retention period above the maximum fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RetentionDaysAboveMaximum_FailsValidation()
|
public void RetentionDaysAboveMaximum_FailsValidation()
|
||||||
{
|
{
|
||||||
@@ -137,6 +145,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default options pass validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultOptions_PassValidation()
|
public void DefaultOptions_PassValidation()
|
||||||
{
|
{
|
||||||
@@ -147,6 +156,7 @@ public class AuditLogOptionsTests
|
|||||||
Assert.True(result.Succeeded, result.FailureMessage);
|
Assert.True(result.Succeeded, result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an invalid retention value bound from configuration is rejected when the options value is accessed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
|
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
|
||||||
{
|
{
|
||||||
|
|||||||
+13
-2
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Task 1 of <c>docs/plans/2026-05-23-inbound-api-full-response-audit.md</c>:
|
/// Per <c>docs/plans/2026-05-23-inbound-api-full-response-audit.md</c>, this class
|
||||||
/// pins the <see cref="AuditLogOptions.InboundMaxBytes"/> default to 1 MiB and
|
/// pins the <see cref="AuditLogOptions.InboundMaxBytes"/> default to 1 MiB and
|
||||||
/// the validator bounds to <c>[8 KiB, 16 MiB]</c>. The inbound channel needs a
|
/// the validator bounds to <c>[8 KiB, 16 MiB]</c>. The inbound channel needs a
|
||||||
/// much larger ceiling than the 8 KiB / 64 KiB default/error caps that other
|
/// much larger ceiling than the 8 KiB / 64 KiB default/error caps that other
|
||||||
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogOptionsValidatorTests
|
public class AuditLogOptionsValidatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the default options set the inbound maximum body size to one mebibyte.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
|
public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,8 @@ public class AuditLogOptionsValidatorTests
|
|||||||
Assert.Equal(1_048_576, opts.InboundMaxBytes);
|
Assert.Equal(1_048_576, opts.InboundMaxBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an inbound maximum body size within the documented range passes validation.</summary>
|
||||||
|
/// <param name="value">The inbound maximum body size in bytes.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(8_192)] // documented min
|
[InlineData(8_192)] // documented min
|
||||||
[InlineData(1_048_576)] // default
|
[InlineData(1_048_576)] // default
|
||||||
@@ -35,6 +38,8 @@ public class AuditLogOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, opts).Succeeded);
|
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an inbound maximum body size outside the documented range fails validation.</summary>
|
||||||
|
/// <param name="value">The inbound maximum body size in bytes.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(0)]
|
[InlineData(0)]
|
||||||
[InlineData(8_191)]
|
[InlineData(8_191)]
|
||||||
@@ -52,9 +57,10 @@ public class AuditLogOptionsValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// M5.5 (T3) per-channel retention overrides
|
// Per-channel retention overrides
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a per-channel retention shorter than the global retention passes validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PerChannelRetention_ShorterThanGlobal_Passes()
|
public void Validate_PerChannelRetention_ShorterThanGlobal_Passes()
|
||||||
{
|
{
|
||||||
@@ -75,6 +81,7 @@ public class AuditLogOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, opts).Succeeded);
|
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a per-channel retention equal to the global retention passes validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PerChannelRetention_EqualToGlobal_Passes()
|
public void Validate_PerChannelRetention_EqualToGlobal_Passes()
|
||||||
{
|
{
|
||||||
@@ -90,6 +97,7 @@ public class AuditLogOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, opts).Succeeded);
|
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a per-channel retention longer than the global retention fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PerChannelRetention_LongerThanGlobal_Fails()
|
public void Validate_PerChannelRetention_LongerThanGlobal_Fails()
|
||||||
{
|
{
|
||||||
@@ -110,6 +118,7 @@ public class AuditLogOptionsValidatorTests
|
|||||||
&& f.Contains("ApiInbound", StringComparison.Ordinal));
|
&& f.Contains("ApiInbound", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a per-channel retention below the minimum fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PerChannelRetention_BelowMinimum_Fails()
|
public void Validate_PerChannelRetention_BelowMinimum_Fails()
|
||||||
{
|
{
|
||||||
@@ -127,6 +136,7 @@ public class AuditLogOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal));
|
f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a per-channel retention keyed by an unknown channel fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PerChannelRetention_UnknownChannelKey_Fails()
|
public void Validate_PerChannelRetention_UnknownChannelKey_Fails()
|
||||||
{
|
{
|
||||||
@@ -146,6 +156,7 @@ public class AuditLogOptionsValidatorTests
|
|||||||
f => f.Contains("NotAChannel", StringComparison.Ordinal));
|
f => f.Contains("NotAChannel", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default empty per-channel retention map passes validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PerChannelRetention_DefaultEmpty_Passes()
|
public void Validate_PerChannelRetention_DefaultEmpty_Passes()
|
||||||
{
|
{
|
||||||
|
|||||||
+35
-9
@@ -36,17 +36,16 @@ using System.Text.Encodings.Web;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 — M4 Bundle E (Task E4) cross-boundary safety suite verifying
|
/// Cross-boundary safety suite verifying
|
||||||
/// the alog.md §13 contract: an always-throwing audit writer NEVER aborts the
|
/// the alog.md §13 contract: an always-throwing audit writer NEVER aborts the
|
||||||
/// user-facing action. Exercises every boundary that emits audit rows in M2,
|
/// user-facing action. Exercises every boundary that emits audit rows:
|
||||||
/// M3, and M4:
|
|
||||||
///
|
///
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><description>External system sync call (M2 Bundle F).</description></item>
|
/// <item><description>External system sync call (sync).</description></item>
|
||||||
/// <item><description>External system cached call (M3 Bundle E).</description></item>
|
/// <item><description>External system cached call (cached).</description></item>
|
||||||
/// <item><description>Database sync write (M4 Bundle A).</description></item>
|
/// <item><description>Database sync write (sync write).</description></item>
|
||||||
/// <item><description>Inbound API request (M4 Bundle D).</description></item>
|
/// <item><description>Inbound API request (request).</description></item>
|
||||||
/// <item><description>Notification dispatcher (M4 Bundle B).</description></item>
|
/// <item><description>Notification dispatcher (dispatch).</description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -64,6 +63,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared migrated MS SQL database fixture.</param>
|
||||||
public AuditWriteFailureSafetyTests(MsSqlMigrationFixture fixture)
|
public AuditWriteFailureSafetyTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -81,8 +82,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
private sealed class ThrowingAuditWriter : IAuditWriter
|
private sealed class ThrowingAuditWriter : IAuditWriter
|
||||||
{
|
{
|
||||||
private int _attempts;
|
private int _attempts;
|
||||||
|
/// <summary>Gets the number of times <c>WriteAsync</c> has been attempted.</summary>
|
||||||
public int Attempts => Volatile.Read(ref _attempts);
|
public int Attempts => Volatile.Read(ref _attempts);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _attempts);
|
Interlocked.Increment(ref _attempts);
|
||||||
@@ -100,8 +103,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
||||||
{
|
{
|
||||||
private int _attempts;
|
private int _attempts;
|
||||||
|
/// <summary>Gets the number of times <c>WriteAsync</c> has been attempted.</summary>
|
||||||
public int Attempts => Volatile.Read(ref _attempts);
|
public int Attempts => Volatile.Read(ref _attempts);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _attempts);
|
Interlocked.Increment(ref _attempts);
|
||||||
@@ -118,8 +123,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
private sealed class ThrowingCachedForwarder : ICachedCallTelemetryForwarder
|
private sealed class ThrowingCachedForwarder : ICachedCallTelemetryForwarder
|
||||||
{
|
{
|
||||||
private int _attempts;
|
private int _attempts;
|
||||||
|
/// <summary>Gets the number of times <c>ForwardAsync</c> has been attempted.</summary>
|
||||||
public int Attempts => Volatile.Read(ref _attempts);
|
public int Attempts => Volatile.Read(ref _attempts);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _attempts);
|
Interlocked.Increment(ref _attempts);
|
||||||
@@ -132,6 +139,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
// Test 1 — ESG sync call still returns the original ExternalCallResult.
|
// Test 1 — ESG sync call still returns the original ExternalCallResult.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a synchronous external system call still returns its result when the audit writer throws.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EsgSyncCall_BrokenAuditWriter_StillReturnsResult()
|
public async Task EsgSyncCall_BrokenAuditWriter_StillReturnsResult()
|
||||||
{
|
{
|
||||||
@@ -171,6 +180,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
// Test 2 — ESG cached call still returns a TrackedOperationId.
|
// Test 2 — ESG cached call still returns a TrackedOperationId.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a cached external system call still returns a tracked operation id when the audit writer throws.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EsgCachedCall_BrokenAuditWriter_StillReturnsTrackedOperationId()
|
public async Task EsgCachedCall_BrokenAuditWriter_StillReturnsTrackedOperationId()
|
||||||
{
|
{
|
||||||
@@ -214,6 +225,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
// Test 3 — DB sync write still returns the rows-affected count.
|
// Test 3 — DB sync write still returns the rows-affected count.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a synchronous database write still returns the rows-affected count when the audit writer throws.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DbSyncWrite_BrokenAuditWriter_StillReturnsRowsAffected()
|
public async Task DbSyncWrite_BrokenAuditWriter_StillReturnsRowsAffected()
|
||||||
{
|
{
|
||||||
@@ -269,6 +282,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
// Test 4 — Inbound API request still returns HTTP 200.
|
// Test 4 — Inbound API request still returns HTTP 200.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that an inbound API request still returns HTTP 200 when the audit writer throws.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InboundApi_BrokenAuditWriter_StillReturns200()
|
public async Task InboundApi_BrokenAuditWriter_StillReturns200()
|
||||||
{
|
{
|
||||||
@@ -290,6 +305,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
// Test 5 — Notification dispatcher still transitions to Delivered.
|
// Test 5 — Notification dispatcher still transitions to Delivered.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a notification dispatch still transitions to delivered when the audit writer throws.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered()
|
public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered()
|
||||||
{
|
{
|
||||||
@@ -381,7 +398,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
private async Task SeedSmtpConfigAsync()
|
private async Task SeedSmtpConfigAsync()
|
||||||
{
|
{
|
||||||
await using var ctx = CreateContext();
|
await using var ctx = CreateContext();
|
||||||
// NO-002: dispatcher clamps non-positive RetryDelay to the 1-minute fallback;
|
// The dispatcher clamps non-positive RetryDelay to the 1-minute fallback;
|
||||||
// use 1 ms so a transient outcome's NextAttemptAt is still effectively due.
|
// use 1 ms so a transient outcome's NextAttemptAt is still effectively due.
|
||||||
ctx.SmtpConfigurations.Add(new SmtpConfiguration(
|
ctx.SmtpConfigurations.Add(new SmtpConfiguration(
|
||||||
"smtp.example.com", "Basic", "noreply@example.com")
|
"smtp.example.com", "Basic", "noreply@example.com")
|
||||||
@@ -418,8 +435,12 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
private sealed class SingleOutcomeAdapter : INotificationDeliveryAdapter
|
private sealed class SingleOutcomeAdapter : INotificationDeliveryAdapter
|
||||||
{
|
{
|
||||||
private readonly DeliveryOutcome _outcome;
|
private readonly DeliveryOutcome _outcome;
|
||||||
|
/// <summary>Initializes the adapter with the outcome to return for every delivery.</summary>
|
||||||
|
/// <param name="outcome">The delivery outcome returned by every call.</param>
|
||||||
public SingleOutcomeAdapter(DeliveryOutcome outcome) { _outcome = outcome; }
|
public SingleOutcomeAdapter(DeliveryOutcome outcome) { _outcome = outcome; }
|
||||||
|
/// <inheritdoc />
|
||||||
public NotificationType Type => NotificationType.Email;
|
public NotificationType Type => NotificationType.Email;
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<DeliveryOutcome> DeliverAsync(
|
public Task<DeliveryOutcome> DeliverAsync(
|
||||||
Notification notification, CancellationToken cancellationToken = default)
|
Notification notification, CancellationToken cancellationToken = default)
|
||||||
=> Task.FromResult(_outcome);
|
=> Task.FromResult(_outcome);
|
||||||
@@ -472,12 +493,17 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
|||||||
: Microsoft.AspNetCore.Authentication.AuthenticationHandler<
|
: Microsoft.AspNetCore.Authentication.AuthenticationHandler<
|
||||||
Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions>
|
Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions>
|
||||||
{
|
{
|
||||||
|
/// <summary>Initializes the handler with the standard authentication handler dependencies.</summary>
|
||||||
|
/// <param name="options">The authentication scheme options monitor.</param>
|
||||||
|
/// <param name="logger">The logger factory.</param>
|
||||||
|
/// <param name="encoder">The URL encoder.</param>
|
||||||
public AlwaysAuthenticatedHandler(
|
public AlwaysAuthenticatedHandler(
|
||||||
IOptionsMonitor<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> options,
|
IOptionsMonitor<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> options,
|
||||||
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||||
UrlEncoder encoder)
|
UrlEncoder encoder)
|
||||||
: base(options, logger, encoder) { }
|
: base(options, logger, encoder) { }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
protected override Task<Microsoft.AspNetCore.Authentication.AuthenticateResult>
|
protected override Task<Microsoft.AspNetCore.Authentication.AuthenticateResult>
|
||||||
HandleAuthenticateAsync()
|
HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-2
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle G G2 end-to-end suite for cached <c>ExternalSystem.CachedCall</c>
|
/// Bundle G G2 end-to-end suite for cached <c>ExternalSystem.CachedCall</c>
|
||||||
/// lifecycle telemetry (Audit Log #23 / M3). Wires the full M3 pipeline:
|
/// lifecycle telemetry. Wires the full pipeline:
|
||||||
/// site-local SQLite audit writer + operation tracking store + the production
|
/// site-local SQLite audit writer + operation tracking store + the production
|
||||||
/// <see cref="CachedCallTelemetryForwarder"/> + the test-side
|
/// <see cref="CachedCallTelemetryForwarder"/> + the test-side
|
||||||
/// <see cref="CombinedTelemetryDispatcher"/> that ALSO pushes each combined
|
/// <see cref="CombinedTelemetryDispatcher"/> that ALSO pushes each combined
|
||||||
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// The bridge is driven directly via <see cref="CombinedTelemetryHarness.EmitAttemptAsync"/>
|
/// The bridge is driven directly via <see cref="CombinedTelemetryHarness.EmitAttemptAsync"/>
|
||||||
/// — these tests do NOT spin up the actual S&F retry loop; that would
|
/// — 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
|
/// observer hooks are exercised in <c>ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests</c> at
|
||||||
/// unit level). The submit row is emitted via
|
/// unit level). The submit row is emitted via
|
||||||
/// <see cref="CombinedTelemetryHarness.EmitSubmitAsync"/> because the
|
/// <see cref="CombinedTelemetryHarness.EmitSubmitAsync"/> because the
|
||||||
@@ -45,6 +45,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test with the shared SQL Server migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MS SQL migration fixture.</param>
|
||||||
public CachedCallCombinedTelemetryTests(MsSqlMigrationFixture fixture)
|
public CachedCallCombinedTelemetryTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -106,6 +108,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
|||||||
DurationMs: 42,
|
DurationMs: 42,
|
||||||
SourceInstanceId: "Plant.Pump42");
|
SourceInstanceId: "Plant.Pump42");
|
||||||
|
|
||||||
|
/// <summary>Verifies that two failed attempts followed by a success emit five audit rows and one delivered site call.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task CachedCall_FailFailSuccess_Emits_5_AuditRows_AND_1_SiteCall_Delivered()
|
public async Task CachedCall_FailFailSuccess_Emits_5_AuditRows_AND_1_SiteCall_Delivered()
|
||||||
{
|
{
|
||||||
@@ -177,6 +181,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
|||||||
Assert.NotNull(snapshot.TerminalAtUtc);
|
Assert.NotNull(snapshot.TerminalAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a cached call whose attempts all fail and are parked emits a terminal Parked row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked()
|
public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked()
|
||||||
{
|
{
|
||||||
@@ -227,6 +233,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
|||||||
Assert.NotNull(snapshot.TerminalAtUtc);
|
Assert.NotNull(snapshot.TerminalAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an immediate success without store-and-forward emits an attempted row and resolves as delivered.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered()
|
public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered()
|
||||||
{
|
{
|
||||||
|
|||||||
+6
@@ -34,6 +34,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared SQL Server migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared SQL Server migration fixture.</param>
|
||||||
public CachedWriteCombinedTelemetryTests(MsSqlMigrationFixture fixture)
|
public CachedWriteCombinedTelemetryTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -93,6 +95,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
DurationMs: 12,
|
DurationMs: 12,
|
||||||
SourceInstanceId: "Plant.Pump42");
|
SourceInstanceId: "Plant.Pump42");
|
||||||
|
|
||||||
|
/// <summary>Verifies that a successful cached write emits the full lifecycle ending in a delivered row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task CachedWrite_Success_Emits_Delivered_Lifecycle()
|
public async Task CachedWrite_Success_Emits_Delivered_Lifecycle()
|
||||||
{
|
{
|
||||||
@@ -144,6 +148,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Assert.Equal("Delivered", snapshot!.Status);
|
Assert.Equal("Delivered", snapshot!.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a parked cached write emits a terminal parked row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task CachedWrite_Parked_Emits_Terminal_Parked()
|
public async Task CachedWrite_Parked_Emits_Terminal_Parked()
|
||||||
{
|
{
|
||||||
|
|||||||
+6
@@ -34,6 +34,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared migrated MS SQL database fixture.</param>
|
||||||
public CombinedTelemetryIdempotencyTests(MsSqlMigrationFixture fixture)
|
public CombinedTelemetryIdempotencyTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -100,6 +102,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a duplicate telemetry packet leaves a single audit row and upserts the site call monotonically.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task DuplicatePacket_AuditLogStaysAtOneRow_SiteCallsUpserted_Monotonically()
|
public async Task DuplicatePacket_AuditLogStaysAtOneRow_SiteCallsUpserted_Monotonically()
|
||||||
{
|
{
|
||||||
@@ -144,6 +148,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Assert.Equal(0, siteCalls[0].RetryCount);
|
Assert.Equal(0, siteCalls[0].RetryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an older status arriving after a newer one is a no-op.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task OutOfOrderPackets_OlderStatus_ArrivesAfterNewer_IsNoOp()
|
public async Task OutOfOrderPackets_OlderStatus_ArrivesAfterNewer_IsNoOp()
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-2
@@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 — M4 Bundle E (Task E1) end-to-end suite verifying every
|
/// End-to-end suite verifying every
|
||||||
/// synchronous <c>Database.Connection(name).Execute*</c> /
|
/// synchronous <c>Database.Connection(name).Execute*</c> /
|
||||||
/// <c>ExecuteReader</c> call made via the Bundle A
|
/// <c>ExecuteReader</c> call made via the Bundle A
|
||||||
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/> emits exactly one
|
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/> emits exactly one
|
||||||
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>:
|
/// Composes the same pipeline as the <see cref="SyncCallEmissionEndToEndTests"/>:
|
||||||
/// in-memory <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
/// in-memory <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
||||||
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
|
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
|
||||||
/// <see cref="SiteAuditTelemetryActor"/> through a
|
/// <see cref="SiteAuditTelemetryActor"/> through a
|
||||||
@@ -57,6 +57,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared migrated MS SQL database fixture.</param>
|
||||||
public DatabaseSyncEmissionEndToEndTests(MsSqlMigrationFixture fixture)
|
public DatabaseSyncEmissionEndToEndTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -170,6 +172,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
sourceScript: SourceScript,
|
sourceScript: SourceScript,
|
||||||
cachedForwarder: null);
|
cachedForwarder: null);
|
||||||
|
|
||||||
|
/// <summary>Verifies that a database insert emits one central audit row carrying a write operation marker and the rows-affected count.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task DbWrite_Insert_Emits_OneCentralRow_WithExtraOpWrite_AndRowsAffected()
|
public async Task DbWrite_Insert_Emits_OneCentralRow_WithExtraOpWrite_AndRowsAffected()
|
||||||
{
|
{
|
||||||
@@ -240,6 +244,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a database select emits one central audit row carrying a read operation marker and the rows-returned count.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task DbWrite_Select_Emits_OneCentralRow_WithExtraOpRead_AndRowsReturned()
|
public async Task DbWrite_Select_Emits_OneCentralRow_WithExtraOpRead_AndRowsReturned()
|
||||||
{
|
{
|
||||||
|
|||||||
+7
-3
@@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the
|
/// ExecutionId end-to-end correlation suite verifying the
|
||||||
/// universal per-run correlation promise: <b>every audit row produced by one
|
/// universal per-run correlation promise: <b>every audit row produced by one
|
||||||
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
|
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -39,8 +39,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
|||||||
/// reads the rows back from the central store and asserts the shared id.
|
/// reads the rows back from the central store and asserts the shared id.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>
|
/// Composes the same pipeline as the sync-call <see cref="SyncCallEmissionEndToEndTests"/>
|
||||||
/// and the M4 <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
|
/// and the database-write <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
|
||||||
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
||||||
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
|
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
|
||||||
/// <see cref="SiteAuditTelemetryActor"/> through the shared
|
/// <see cref="SiteAuditTelemetryActor"/> through the shared
|
||||||
@@ -57,6 +57,8 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MS SQL migration fixture.</param>
|
||||||
public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
|
public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -146,6 +148,8 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
|
|||||||
FastTelemetryOptions(),
|
FastTelemetryOptions(),
|
||||||
NullLogger<SiteAuditTelemetryActor>.Instance)));
|
NullLogger<SiteAuditTelemetryActor>.Instance)));
|
||||||
|
|
||||||
|
/// <summary>Verifies that one execution performing an API call and a database write produces central rows that all share one non-null execution id.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId()
|
public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ using System.Text.Encodings.Web;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 — M4 Bundle E (Task E3) end-to-end audit trail for the
|
/// End-to-end audit trail for the
|
||||||
/// inbound API surface. Wires the production
|
/// inbound API surface. Wires the production
|
||||||
/// <see cref="AuditWriteMiddleware"/> into a Microsoft.AspNetCore.TestHost
|
/// <see cref="AuditWriteMiddleware"/> into a Microsoft.AspNetCore.TestHost
|
||||||
/// pipeline that mirrors the production
|
/// pipeline that mirrors the production
|
||||||
@@ -53,6 +53,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MS SQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MS SQL migration fixture.</param>
|
||||||
public InboundApiAuditTests(MsSqlMigrationFixture fixture)
|
public InboundApiAuditTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -144,12 +146,17 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
{
|
{
|
||||||
|
/// <summary>Initializes the handler with the standard authentication-handler dependencies.</summary>
|
||||||
|
/// <param name="options">The authentication scheme options monitor.</param>
|
||||||
|
/// <param name="logger">The logger factory.</param>
|
||||||
|
/// <param name="encoder">The URL encoder.</param>
|
||||||
public AlwaysAuthenticatedHandler(
|
public AlwaysAuthenticatedHandler(
|
||||||
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
|
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||||
UrlEncoder encoder)
|
UrlEncoder encoder)
|
||||||
: base(options, logger, encoder) { }
|
: base(options, logger, encoder) { }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
var identity = new ClaimsIdentity(
|
var identity = new ClaimsIdentity(
|
||||||
@@ -200,6 +207,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
return Assert.Single(finalRows);
|
return Assert.Single(finalRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a post with a valid actor emits a delivered inbound-request audit row with HTTP status 200 and the actor populated.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated()
|
public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated()
|
||||||
{
|
{
|
||||||
@@ -232,6 +241,8 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.NotNull(evt.AsRow().IngestedAtUtc);
|
Assert.NotNull(evt.AsRow().IngestedAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a post without authentication emits a failed inbound-auth-failure audit row with HTTP status 401 and no actor.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task PostToApi_Without_Auth_Emits_InboundAuthFailure_StatusFailed_HttpStatus401_ActorNull()
|
public async Task PostToApi_Without_Auth_Emits_InboundAuthFailure_StatusFailed_HttpStatus401_ActorNull()
|
||||||
{
|
{
|
||||||
@@ -260,12 +271,14 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
|
Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
|
||||||
Assert.Equal(401, evt.AsRow().HttpStatus);
|
Assert.Equal(401, evt.AsRow().HttpStatus);
|
||||||
// Never echo back an unauthenticated principal — middleware suppresses
|
// Never echo back an unauthenticated principal — middleware suppresses
|
||||||
// the framework user resolution on 401/403 paths. C3 (Task 2.5): the
|
// the framework user resolution on 401/403 paths. The
|
||||||
// canonical Actor is a non-null string (empty when absent); the row view
|
// canonical Actor is a non-null string (empty when absent); the row view
|
||||||
// maps empty → null, preserving the "no principal" assertion.
|
// maps empty → null, preserving the "no principal" assertion.
|
||||||
Assert.Null(evt.AsRow().Actor);
|
Assert.Null(evt.AsRow().Actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a post whose endpoint returns 500 emits a failed inbound-request audit row with HTTP status 500.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500()
|
public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500()
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-6
@@ -20,12 +20,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Production wiring keeps the wire push deferred to M6 — the site SQLite hot
|
/// Production wiring keeps the wire push deferred — the site SQLite hot
|
||||||
/// path is the source of truth and a future M6 drain will push the rows
|
/// path is the source of truth and a future drain will push the rows
|
||||||
/// through the gRPC client. For end-to-end testing today we need a way to
|
/// through the gRPC client. For end-to-end testing today we need a way to
|
||||||
/// exercise the central dual-write transaction immediately, so this
|
/// exercise the central dual-write transaction immediately, so this
|
||||||
/// dispatcher synthesises the wire packet inline and round-trips it through
|
/// dispatcher synthesises the wire packet inline and round-trips it through
|
||||||
/// the stub client. The shape mirrors what the M6 drain will eventually emit.
|
/// the stub client. The shape mirrors what the future drain will eventually emit.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Best-effort:</b> both the inner forwarder call and the wire push are
|
/// <b>Best-effort:</b> both the inner forwarder call and the wire push are
|
||||||
@@ -40,6 +40,9 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
|
|||||||
private readonly ICachedCallTelemetryForwarder _inner;
|
private readonly ICachedCallTelemetryForwarder _inner;
|
||||||
private readonly ISiteStreamAuditClient _wireClient;
|
private readonly ISiteStreamAuditClient _wireClient;
|
||||||
|
|
||||||
|
/// <summary>Initializes the dispatcher with the inner forwarder and the wire client.</summary>
|
||||||
|
/// <param name="inner">The forwarder that handles site-local telemetry.</param>
|
||||||
|
/// <param name="wireClient">The client used to push audit rows over the wire.</param>
|
||||||
public CombinedTelemetryDispatcher(
|
public CombinedTelemetryDispatcher(
|
||||||
ICachedCallTelemetryForwarder inner,
|
ICachedCallTelemetryForwarder inner,
|
||||||
ISiteStreamAuditClient wireClient)
|
ISiteStreamAuditClient wireClient)
|
||||||
@@ -48,7 +51,8 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
|
|||||||
_wireClient = wireClient ?? throw new ArgumentNullException(nameof(wireClient));
|
_wireClient = wireClient ?? throw new ArgumentNullException(nameof(wireClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
// Forwards the telemetry through the inner forwarder and then pushes its audit rows to the wire client.
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(telemetry);
|
ArgumentNullException.ThrowIfNull(telemetry);
|
||||||
@@ -67,7 +71,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Project the same packet onto the wire and push it through the stub
|
// Project the same packet onto the wire and push it through the stub
|
||||||
// client. This is the bit a future M6 drain will subsume — until
|
// client. This is the bit a future drain will subsume — until
|
||||||
// then the test wraps the two halves into one observer-driven step.
|
// then the test wraps the two halves into one observer-driven step.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -79,7 +83,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
|
|||||||
{
|
{
|
||||||
// Swallow — the audit row is still in SQLite for a future drain;
|
// Swallow — the audit row is still in SQLite for a future drain;
|
||||||
// the central row will materialise the next time the wire path
|
// the central row will materialise the next time the wire path
|
||||||
// is exercised (or via the M6 reconciliation pull).
|
// is exercised (or via the reconciliation pull).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared end-to-end harness for the M3 cached-call combined telemetry tests
|
/// Shared end-to-end harness for the cached-call combined telemetry tests
|
||||||
/// (G2/G3/G4). Composes the full pipeline:
|
/// (G2/G3/G4). Composes the full pipeline:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><description>Site-local SQLite <see cref="SqliteAuditWriter"/> (in-memory) +
|
/// <item><description>Site-local SQLite <see cref="SqliteAuditWriter"/> (in-memory) +
|
||||||
@@ -48,15 +48,25 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>The site SQLite audit writer under test.</summary>
|
||||||
public SqliteAuditWriter SqliteWriter { get; }
|
public SqliteAuditWriter SqliteWriter { get; }
|
||||||
|
/// <summary>The in-memory ring buffer fallback for audit events.</summary>
|
||||||
public RingBufferFallback Ring { get; }
|
public RingBufferFallback Ring { get; }
|
||||||
|
/// <summary>The fallback audit writer that wraps the SQLite writer and the ring buffer.</summary>
|
||||||
public FallbackAuditWriter FallbackWriter { get; }
|
public FallbackAuditWriter FallbackWriter { get; }
|
||||||
|
/// <summary>The site-local operation tracking store.</summary>
|
||||||
public OperationTrackingStore TrackingStore { get; }
|
public OperationTrackingStore TrackingStore { get; }
|
||||||
|
/// <summary>The inner cached-call telemetry forwarder.</summary>
|
||||||
public CachedCallTelemetryForwarder InnerForwarder { get; }
|
public CachedCallTelemetryForwarder InnerForwarder { get; }
|
||||||
|
/// <summary>The combined telemetry dispatcher that sends audit and operational state together.</summary>
|
||||||
public CombinedTelemetryDispatcher Dispatcher { get; }
|
public CombinedTelemetryDispatcher Dispatcher { get; }
|
||||||
|
/// <summary>The cached-call lifecycle bridge wired to the forwarder.</summary>
|
||||||
public CachedCallLifecycleBridge Bridge { get; }
|
public CachedCallLifecycleBridge Bridge { get; }
|
||||||
|
/// <summary>The stub site-stream audit client that delivers packets directly to the ingest actor.</summary>
|
||||||
public DirectActorSiteStreamAuditClient StubClient { get; }
|
public DirectActorSiteStreamAuditClient StubClient { get; }
|
||||||
|
/// <summary>The central audit ingest actor.</summary>
|
||||||
public IActorRef IngestActor { get; }
|
public IActorRef IngestActor { get; }
|
||||||
|
/// <summary>The service provider backing the central repositories.</summary>
|
||||||
public IServiceProvider ServiceProvider { get; }
|
public IServiceProvider ServiceProvider { get; }
|
||||||
|
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
@@ -64,6 +74,10 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
|||||||
private readonly ServiceProvider _trackingLocalDbProvider;
|
private readonly ServiceProvider _trackingLocalDbProvider;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Builds the harness, wiring the site writer chain, dispatcher and central ingest actor against the shared fixture.</summary>
|
||||||
|
/// <param name="fixture">The MS SQL migration fixture providing the central database.</param>
|
||||||
|
/// <param name="testKit">The Akka test kit that hosts the ingest actor.</param>
|
||||||
|
/// <param name="siteCallRepoOverride">Optional factory that replaces the default site-call audit repository.</param>
|
||||||
public CombinedTelemetryHarness(
|
public CombinedTelemetryHarness(
|
||||||
MsSqlMigrationFixture fixture,
|
MsSqlMigrationFixture fixture,
|
||||||
Akka.TestKit.Xunit2.TestKit testKit,
|
Akka.TestKit.Xunit2.TestKit testKit,
|
||||||
@@ -133,15 +147,23 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
|||||||
/// dispatcher (the bridge's hooks fire only for S&F retry-loop
|
/// dispatcher (the bridge's hooks fire only for S&F retry-loop
|
||||||
/// attempts; submit-row emission happens at the script call site).
|
/// attempts; submit-row emission happens at the script call site).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="submit">The submit packet to forward.</param>
|
||||||
|
/// <param name="ct">A token to cancel the forward.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public Task EmitSubmitAsync(CachedCallTelemetry submit, CancellationToken ct = default) =>
|
public Task EmitSubmitAsync(CachedCallTelemetry submit, CancellationToken ct = default) =>
|
||||||
Dispatcher.ForwardAsync(submit, ct);
|
Dispatcher.ForwardAsync(submit, ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convenience: route a per-attempt or terminal outcome through the bridge.
|
/// Convenience: route a per-attempt or terminal outcome through the bridge.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="context">The attempt or terminal-outcome context to route.</param>
|
||||||
|
/// <param name="ct">A token to cancel the emit.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public Task EmitAttemptAsync(CachedCallAttemptContext context, CancellationToken ct = default) =>
|
public Task EmitAttemptAsync(CachedCallAttemptContext context, CancellationToken ct = default) =>
|
||||||
Bridge.OnAttemptCompletedAsync(context, ct);
|
Bridge.OnAttemptCompletedAsync(context, ct);
|
||||||
|
|
||||||
|
/// <summary>Creates a fresh central database context for reading back persisted rows.</summary>
|
||||||
|
/// <returns>A new <see cref="ScadaBridgeDbContext"/>.</returns>
|
||||||
public ScadaBridgeDbContext CreateReadContext()
|
public ScadaBridgeDbContext CreateReadContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||||
@@ -173,6 +195,8 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
|
|||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the resources held by this test class asynchronously.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|||||||
+12
-16
@@ -12,8 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
|||||||
/// Shared component-level <see cref="ISiteStreamAuditClient"/> test double that
|
/// Shared component-level <see cref="ISiteStreamAuditClient"/> test double that
|
||||||
/// short-circuits the gRPC wire and forwards each batch directly to a central
|
/// short-circuits the gRPC wire and forwards each batch directly to a central
|
||||||
/// <see cref="AuditLog.Central.AuditLogIngestActor"/> via Akka <see cref="Futures.Ask"/>.
|
/// <see cref="AuditLog.Central.AuditLogIngestActor"/> via Akka <see cref="Futures.Ask"/>.
|
||||||
/// Lives under <c>Integration/Infrastructure/</c> so both the M2 sync-call and
|
/// Lives under <c>Integration/Infrastructure/</c> so both the sync-call and
|
||||||
/// M3 cached-call end-to-end suites can reuse it.
|
/// cached-call end-to-end suites can reuse it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -27,8 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <see cref="FailNextCallCount"/> arms a deterministic number of failures
|
/// <see cref="FailNextCallCount"/> arms a deterministic number of failures
|
||||||
/// before the stub recovers; it applies to BOTH RPCs because the M2 sync-call
|
/// before the stub recovers; it applies to BOTH RPCs because the sync-call
|
||||||
/// retry behaviour and the M3 cached-telemetry retry behaviour share a single
|
/// retry behaviour and the cached-telemetry retry behaviour share a single
|
||||||
/// SiteAuditTelemetryActor drain. Tests that need to differentiate per-RPC
|
/// SiteAuditTelemetryActor drain. Tests that need to differentiate per-RPC
|
||||||
/// failures should reach for a per-test wrapper rather than extending this
|
/// failures should reach for a per-test wrapper rather than extending this
|
||||||
/// shared infrastructure.
|
/// shared infrastructure.
|
||||||
@@ -41,6 +41,8 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
private int _callCount;
|
private int _callCount;
|
||||||
private int _cachedTelemetryCallCount;
|
private int _cachedTelemetryCallCount;
|
||||||
|
|
||||||
|
/// <summary>Initializes the client with the central ingest actor that receives every forwarded batch.</summary>
|
||||||
|
/// <param name="ingestActor">The central <c>AuditLogIngestActor</c> reference batches are asked of.</param>
|
||||||
public DirectActorSiteStreamAuditClient(IActorRef ingestActor)
|
public DirectActorSiteStreamAuditClient(IActorRef ingestActor)
|
||||||
{
|
{
|
||||||
_ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor));
|
_ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor));
|
||||||
@@ -69,6 +71,7 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int CachedTelemetryCallCount => Volatile.Read(ref _cachedTelemetryCallCount);
|
public int CachedTelemetryCallCount => Volatile.Read(ref _cachedTelemetryCallCount);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
|
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _callCount);
|
Interlocked.Increment(ref _callCount);
|
||||||
@@ -106,18 +109,11 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
return ack;
|
return ack;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Dual-write path: decodes each CachedTelemetryPacket into the paired (AuditEvent, SiteCall)
|
||||||
/// M3 dual-write path: decode each <see cref="CachedTelemetryPacket"/> into
|
// entry via the shared AuditEventDtoMapper.FromDto and SiteCallDtoMapper.FromDto (the same
|
||||||
/// the paired (<see cref="AuditEvent"/>, <see cref="SiteCall"/>) entry and
|
// canonical mappers the production SiteStreamGrpcServer uses), then Asks the central ingest
|
||||||
/// Ask the central ingest actor with an <see cref="IngestCachedTelemetryCommand"/>.
|
// actor with an IngestCachedTelemetryCommand; the accepted EventIds map back into the proto ack.
|
||||||
/// The accepted EventIds returned by the actor's dual-write transaction map
|
/// <inheritdoc />
|
||||||
/// back into the proto ack.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Uses the shared <see cref="AuditEventDtoMapper.FromDto"/> for the audit half
|
|
||||||
/// and <see cref="SiteCallDtoMapper.FromDto"/> for the SiteCall half — the same
|
|
||||||
/// canonical mappers the production <c>SiteStreamGrpcServer</c> uses.
|
|
||||||
/// </remarks>
|
|
||||||
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _cachedTelemetryCallCount);
|
Interlocked.Increment(ref _cachedTelemetryCallCount);
|
||||||
|
|||||||
+14
-2
@@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 — M4 Bundle E (Task E2): end-to-end audit trail produced by
|
/// End-to-end audit trail produced by
|
||||||
/// the central <see cref="NotificationOutboxActor"/> dispatcher loop. Wires
|
/// the central <see cref="NotificationOutboxActor"/> dispatcher loop. Wires
|
||||||
/// the production <see cref="CentralAuditWriter"/> onto the real
|
/// the production <see cref="CentralAuditWriter"/> onto the real
|
||||||
/// <see cref="AuditLogRepository"/> against the per-class
|
/// <see cref="AuditLogRepository"/> against the per-class
|
||||||
@@ -51,6 +51,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||||||
public NotifyDispatcherAuditTrailTests(MsSqlMigrationFixture fixture)
|
public NotifyDispatcherAuditTrailTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -105,13 +107,17 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
private readonly Queue<DeliveryOutcome> _outcomes;
|
private readonly Queue<DeliveryOutcome> _outcomes;
|
||||||
public int CallCount;
|
public int CallCount;
|
||||||
|
|
||||||
|
/// <summary>Initializes the adapter with the delivery outcomes it returns in order.</summary>
|
||||||
|
/// <param name="outcomes">The outcomes returned by successive delivery calls.</param>
|
||||||
public QueuedOutcomeAdapter(params DeliveryOutcome[] outcomes)
|
public QueuedOutcomeAdapter(params DeliveryOutcome[] outcomes)
|
||||||
{
|
{
|
||||||
_outcomes = new Queue<DeliveryOutcome>(outcomes);
|
_outcomes = new Queue<DeliveryOutcome>(outcomes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public NotificationType Type => NotificationType.Email;
|
public NotificationType Type => NotificationType.Email;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<DeliveryOutcome> DeliverAsync(
|
public Task<DeliveryOutcome> DeliverAsync(
|
||||||
Notification notification, CancellationToken cancellationToken = default)
|
Notification notification, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -133,7 +139,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
/// pair rather than the conservative fallback. A tiny positive RetryDelay
|
/// pair rather than the conservative fallback. A tiny positive RetryDelay
|
||||||
/// means a transient outcome's <c>NextAttemptAt</c> is immediately due —
|
/// means a transient outcome's <c>NextAttemptAt</c> is immediately due —
|
||||||
/// useful so the SECOND DispatchTick re-claims the row without waiting.
|
/// useful so the SECOND DispatchTick re-claims the row without waiting.
|
||||||
/// NO-002: the dispatcher now clamps a non-positive RetryDelay to the
|
/// The dispatcher now clamps a non-positive RetryDelay to the
|
||||||
/// 1-minute fallback to avoid burn-looping on transient failures, so this
|
/// 1-minute fallback to avoid burn-looping on transient failures, so this
|
||||||
/// must be a strictly positive value (1 ms is fine for tests).
|
/// must be a strictly positive value (1 ms is fine for tests).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -242,6 +248,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
PurgeInterval = TimeSpan.FromDays(1),
|
PurgeInterval = TimeSpan.FromDays(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Verifies that a transient failure followed by a success emits two attempt rows and a terminal delivered row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task NotifyDispatcher_FailThenSuccess_Emits_TwoAttempts_OneDelivered_Terminal()
|
public async Task NotifyDispatcher_FailThenSuccess_Emits_TwoAttempts_OneDelivered_Terminal()
|
||||||
{
|
{
|
||||||
@@ -319,6 +327,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
StopDispatcher(actor);
|
StopDispatcher(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a throwing audit writer does not prevent the notification delivery from succeeding.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds()
|
public async Task NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds()
|
||||||
{
|
{
|
||||||
@@ -394,8 +404,10 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
||||||
{
|
{
|
||||||
private int _attemptCount;
|
private int _attemptCount;
|
||||||
|
/// <summary>Gets the number of write attempts made against this writer.</summary>
|
||||||
public int AttemptCount => Volatile.Read(ref _attemptCount);
|
public int AttemptCount => Volatile.Read(ref _attemptCount);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _attemptCount);
|
Interlocked.Increment(ref _attemptCount);
|
||||||
|
|||||||
+18
-5
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle F (#23 M6-T10) end-to-end test for the central-outage + reconciliation
|
/// End-to-end test for the central-outage + reconciliation
|
||||||
/// recovery loop. Wires the real site SQLite hot-path
|
/// recovery loop. Wires the real site SQLite hot-path
|
||||||
/// (<see cref="SqliteAuditWriter"/>) and the central <see cref="SiteAuditReconciliationActor"/>
|
/// (<see cref="SqliteAuditWriter"/>) and the central <see cref="SiteAuditReconciliationActor"/>
|
||||||
/// with an <see cref="AuditLogIngestActor"/> backed by the real
|
/// with an <see cref="AuditLogIngestActor"/> backed by the real
|
||||||
@@ -39,7 +39,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
|||||||
/// the real repository.
|
/// the real repository.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The <see cref="CombinedTelemetryHarness"/> from M3 is push-only — it has no
|
/// The <see cref="CombinedTelemetryHarness"/> is push-only — it has no
|
||||||
/// reconciliation puller — so we build the smaller stub inline rather than
|
/// reconciliation puller — so we build the smaller stub inline rather than
|
||||||
/// retrofitting the shared harness with a code path it doesn't otherwise
|
/// retrofitting the shared harness with a code path it doesn't otherwise
|
||||||
/// need.
|
/// need.
|
||||||
@@ -49,6 +49,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MS SQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MS SQL migration fixture.</param>
|
||||||
public OutageReconciliationTests(MsSqlMigrationFixture fixture)
|
public OutageReconciliationTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -77,19 +79,23 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
private sealed class QueueBackedPullClient : IPullAuditEventsClient
|
private sealed class QueueBackedPullClient : IPullAuditEventsClient
|
||||||
{
|
{
|
||||||
private readonly ISiteAuditQueue _siteQueue;
|
private readonly ISiteAuditQueue _siteQueue;
|
||||||
|
/// <summary>Gets the number of times <c>PullAsync</c> has been invoked.</summary>
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Initializes the client over the given site audit queue.</summary>
|
||||||
|
/// <param name="siteQueue">The site audit queue to pull from.</param>
|
||||||
public QueueBackedPullClient(ISiteAuditQueue siteQueue)
|
public QueueBackedPullClient(ISiteAuditQueue siteQueue)
|
||||||
{
|
{
|
||||||
_siteQueue = siteQueue ?? throw new ArgumentNullException(nameof(siteQueue));
|
_siteQueue = siteQueue ?? throw new ArgumentNullException(nameof(siteQueue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task<PullAuditEventsResponse> PullAsync(
|
public async Task<PullAuditEventsResponse> PullAsync(
|
||||||
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
||||||
{
|
{
|
||||||
CallCount++;
|
CallCount++;
|
||||||
|
|
||||||
// Mirrors SiteStreamGrpcServer.PullAuditEvents exactly (WP2.3): the
|
// Mirrors SiteStreamGrpcServer.PullAuditEvents exactly: the
|
||||||
// INCOMING cursor is central's receipt, so everything at or before it
|
// INCOMING cursor is central's receipt, so everything at or before it
|
||||||
// is retired FIRST; the rows this call serves are NOT retired, because
|
// is retired FIRST; the rows this call serves are NOT retired, because
|
||||||
// nothing yet proves central consumed them. A fault between here and
|
// nothing yet proves central consumed them. A fault between here and
|
||||||
@@ -119,7 +125,10 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
private sealed class StaticEnumerator : ISiteEnumerator
|
private sealed class StaticEnumerator : ISiteEnumerator
|
||||||
{
|
{
|
||||||
private readonly IReadOnlyList<SiteEntry> _sites;
|
private readonly IReadOnlyList<SiteEntry> _sites;
|
||||||
|
/// <summary>Initializes the enumerator with a fixed set of sites.</summary>
|
||||||
|
/// <param name="sites">The fixed sites to enumerate.</param>
|
||||||
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
|
||||||
Task.FromResult(_sites);
|
Task.FromResult(_sites);
|
||||||
}
|
}
|
||||||
@@ -208,6 +217,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
// 1. CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates
|
// 1. CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that 200 events buffered during a central outage are caught up by reconciliation with no duplicates.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates()
|
public async Task CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates()
|
||||||
{
|
{
|
||||||
@@ -297,8 +308,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
interval: TimeSpan.FromMilliseconds(100));
|
interval: TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
// Both actors have done everything the test needs; stop them HERE, before the
|
// Both actors have done everything the test needs; stop them HERE, before the
|
||||||
// read-back, so nothing is still writing to the shared fixture DB while step 5
|
// read-back, so nothing is still writing to the shared fixture DB while the read-back
|
||||||
// counts rows and so a step-5 failure cannot leak them (register row 95).
|
// counts rows and so a read-back failure cannot leak them (register row 95).
|
||||||
StopActor(reconciliationActor);
|
StopActor(reconciliationActor);
|
||||||
StopActor(ingest);
|
StopActor(ingest);
|
||||||
|
|
||||||
@@ -321,6 +332,8 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
// 2. ReconciliationPull_Idempotent_Across_Two_Cycles
|
// 2. ReconciliationPull_Idempotent_Across_Two_Cycles
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that repeated reconciliation pull cycles are idempotent.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task ReconciliationPull_Idempotent_Across_Two_Cycles()
|
public async Task ReconciliationPull_Idempotent_Across_Two_Cycles()
|
||||||
{
|
{
|
||||||
|
|||||||
+19
-2
@@ -40,7 +40,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 — <b>ParentExecutionId cross-execution correlation</b> headline
|
/// <b>ParentExecutionId cross-execution correlation</b> headline
|
||||||
/// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an
|
/// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an
|
||||||
/// inbound HTTP request runs an inbound method script that calls
|
/// inbound HTTP request runs an inbound method script that calls
|
||||||
/// <c>Route.Call</c> into a site instance; the routed site script does a sync
|
/// <c>Route.Call</c> into a site instance; the routed site script does a sync
|
||||||
@@ -101,6 +101,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MS SQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MS SQL migration fixture.</param>
|
||||||
public ParentExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
|
public ParentExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -126,6 +128,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
return new ScadaBridgeDbContext(options);
|
return new ScadaBridgeDbContext(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that every audit row of a routed site run carries the inbound request's execution id as its parent execution id.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId()
|
public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId()
|
||||||
{
|
{
|
||||||
@@ -507,7 +511,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
var pending = await sqliteWriter.ReadPendingAsync(256);
|
var pending = await sqliteWriter.ReadPendingAsync(256);
|
||||||
// AuditLog-001: ReadPendingAsync now excludes the cached-lifecycle
|
// ReadPendingAsync now excludes the cached-lifecycle
|
||||||
// kinds (they ride the combined-telemetry drain), so we union
|
// kinds (they ride the combined-telemetry drain), so we union
|
||||||
// them in via the dedicated read surface to keep the durability
|
// them in via the dedicated read surface to keep the durability
|
||||||
// assertion covering EVERY expected Kind.
|
// assertion covering EVERY expected Kind.
|
||||||
@@ -534,8 +538,10 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter
|
private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public NotificationType Type => NotificationType.Email;
|
public NotificationType Type => NotificationType.Email;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<DeliveryOutcome> DeliverAsync(
|
public Task<DeliveryOutcome> DeliverAsync(
|
||||||
ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification notification,
|
ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification notification,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -568,6 +574,12 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? NotificationId { get; private set; }
|
public string? NotificationId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Initializes the router with the collaborators the routed script run needs.</summary>
|
||||||
|
/// <param name="siteId">The site identifier stamped on routed runs.</param>
|
||||||
|
/// <param name="externalClient">The external system client used by the routed script.</param>
|
||||||
|
/// <param name="auditWriter">The audit writer the routed script emits rows through.</param>
|
||||||
|
/// <param name="cachedForwarder">The cached-call telemetry forwarder.</param>
|
||||||
|
/// <param name="storeAndForward">The store-and-forward service that buffers the notification.</param>
|
||||||
public BridgingInstanceRouter(
|
public BridgingInstanceRouter(
|
||||||
string siteId,
|
string siteId,
|
||||||
IExternalSystemClient externalClient,
|
IExternalSystemClient externalClient,
|
||||||
@@ -582,6 +594,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
_storeAndForward = storeAndForward;
|
_storeAndForward = storeAndForward;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs a routed site script body locally, with the inbound execution id as its parent execution id.
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task<RouteToCallResponse> RouteToCallAsync(
|
public async Task<RouteToCallResponse> RouteToCallAsync(
|
||||||
string siteId, RouteToCallRequest request, CancellationToken cancellationToken)
|
string siteId, RouteToCallRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -626,16 +640,19 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
request.CorrelationId, true, "routed-ok", null, DateTimeOffset.UtcNow);
|
request.CorrelationId, true, "routed-ok", null, DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
|
public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
|
||||||
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken)
|
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
|
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
|
||||||
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
|
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
// WaitForAttribute is not part of this fixture's routed-Call audit scenario;
|
// WaitForAttribute is not part of this fixture's routed-Call audit scenario;
|
||||||
// mirror the other non-Call methods (unexercised here).
|
// mirror the other non-Call methods (unexercised here).
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
|
public Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
|
||||||
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken)
|
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle F (#23 M6-T12) end-to-end tests for the
|
/// End-to-end tests for the
|
||||||
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service running
|
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service running
|
||||||
/// the real EF/MSSQL <see cref="AuditLogPartitionMaintenance"/> against the
|
/// the real EF/MSSQL <see cref="AuditLogPartitionMaintenance"/> against the
|
||||||
/// per-class <see cref="MsSqlMigrationFixture"/>. The migration seeds
|
/// per-class <see cref="MsSqlMigrationFixture"/>. The migration seeds
|
||||||
@@ -30,6 +30,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with the shared MS SQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The shared MS SQL migration fixture.</param>
|
||||||
public PartitionMaintenanceTests(MsSqlMigrationFixture fixture)
|
public PartitionMaintenanceTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -116,6 +118,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
// 1. EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered
|
// 1. EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default lookahead adds no new partition boundaries when future partitions are already covered.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered()
|
public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered()
|
||||||
{
|
{
|
||||||
@@ -162,6 +166,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
// 2. EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries
|
// 2. EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a lookahead larger than the covered range splits in new partition boundaries.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries()
|
public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries()
|
||||||
{
|
{
|
||||||
@@ -218,6 +224,8 @@ public class PartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
// 3. EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns
|
// 3. EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that running partition maintenance twice adds boundaries only once.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns()
|
public async Task EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle F (#23 M6-T11) end-to-end test for the daily partition-switch
|
/// End-to-end test for the daily partition-switch
|
||||||
/// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct
|
/// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct
|
||||||
/// INSERTs that bypass the standard repository ingest path (so the seed
|
/// INSERTs that bypass the standard repository ingest path (so the seed
|
||||||
/// timestamps are explicit), drives <see cref="AuditLogPurgeActor"/> against
|
/// timestamps are explicit), drives <see cref="AuditLogPurgeActor"/> against
|
||||||
@@ -46,6 +46,8 @@ public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="PartitionPurgeTests"/> class.</summary>
|
||||||
|
/// <param name="fixture">The fixture used by the tests.</param>
|
||||||
public PartitionPurgeTests(MsSqlMigrationFixture fixture)
|
public PartitionPurgeTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -71,11 +73,11 @@ public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
|||||||
string kind = "ApiCall")
|
string kind = "ApiCall")
|
||||||
{
|
{
|
||||||
await using var cmd = conn.CreateCommand();
|
await using var cmd = conn.CreateCommand();
|
||||||
// C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson;
|
// dbo.AuditLog is now the 10 canonical columns + DetailsJson;
|
||||||
// the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in
|
// the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in
|
||||||
// DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive.
|
// DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive.
|
||||||
// Action = "{channel}.{kind}", Category = channel name, Outcome = Success.
|
// Action = "{channel}.{kind}", Category = channel name, Outcome = Success.
|
||||||
// The channel/kind are parameterized so the M5.5 per-channel purge test can
|
// The channel/kind are parameterized so the per-channel purge test can
|
||||||
// seed multiple channels into the same partition.
|
// seed multiple channels into the same partition.
|
||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
INSERT INTO dbo.AuditLog
|
INSERT INTO dbo.AuditLog
|
||||||
@@ -111,7 +113,7 @@ VALUES
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Asserts the post-purge index state: the partition-ALIGNED clustered
|
/// Asserts the post-purge index state: the partition-ALIGNED clustered
|
||||||
/// <c>PK_AuditLog</c> is intact and the non-aligned <c>UX_AuditLog_EventId</c>
|
/// <c>PK_AuditLog</c> is intact and the non-aligned <c>UX_AuditLog_EventId</c>
|
||||||
/// is absent (WP2.2 — <c>AlignAuditLogEventIdUniqueness</c>).
|
/// is absent (<c>AlignAuditLogEventIdUniqueness</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The purge used to bracket its SWITCH with a DROP/CREATE of
|
/// The purge used to bracket its SWITCH with a DROP/CREATE of
|
||||||
@@ -164,7 +166,7 @@ SELECT
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Deferred-work register row 82. Every actor these tests create ticks every
|
/// Deferred-work register row 82. Every actor these tests create ticks every
|
||||||
/// <c>100 ms</c> (<see cref="AuditLogPurgeOptions.IntervalOverride"/>) and switches
|
/// <c>100 ms</c> (<see cref="AuditLogPurgeOptions.IntervalOverride"/>) and switches
|
||||||
/// out the Jan-2026 partition each time it finds one eligible. The tests relied on
|
/// out the January 2026 partition each time it finds one eligible. The tests relied on
|
||||||
/// <see cref="TestKit"/>'s <c>Dispose</c> → <c>Shutdown</c> to stop them between
|
/// <see cref="TestKit"/>'s <c>Dispose</c> → <c>Shutdown</c> to stop them between
|
||||||
/// test methods, but that shutdown is bounded by a timeout and does NOT fail when
|
/// test methods, but that shutdown is bounded by a timeout and does NOT fail when
|
||||||
/// it expires — so on a loaded machine a previous test's actor was still ticking
|
/// it expires — so on a loaded machine a previous test's actor was still ticking
|
||||||
@@ -172,9 +174,9 @@ SELECT
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// That is what made <c>PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows</c>
|
/// That is what made <c>PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows</c>
|
||||||
/// flake under a parallel full-solution run: it seeds a Jan-15-2026 row and expects
|
/// flake under a parallel full-solution run: it seeds a 2026-01-15 row and expects
|
||||||
/// its own per-channel DELETE to remove exactly one row, but a leaked actor could
|
/// its own per-channel DELETE to remove exactly one row, but a leaked actor could
|
||||||
/// switch the whole Jan-2026 partition out first, leaving nothing to delete
|
/// switch the whole January 2026 partition out first, leaving nothing to delete
|
||||||
/// (observed: <c>Assert.Equal() Failure … Expected: 1, Actual: 0</c>). Stopping the
|
/// (observed: <c>Assert.Equal() Failure … Expected: 1, Actual: 0</c>). Stopping the
|
||||||
/// actor inside the test that created it removes the dependency on shutdown timing
|
/// actor inside the test that created it removes the dependency on shutdown timing
|
||||||
/// altogether.
|
/// altogether.
|
||||||
@@ -228,9 +230,9 @@ SELECT
|
|||||||
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
|
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in Jan-2026 partition → PURGED
|
Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in January 2026 partition → PURGED
|
||||||
Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in Apr-2026 partition → KEPT
|
Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in April 2026 partition → KEPT
|
||||||
Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in Jun-2026 partition → KEPT
|
Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in June 2026 partition → KEPT
|
||||||
RetentionDays: retentionDays
|
RetentionDays: retentionDays
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -239,6 +241,8 @@ SELECT
|
|||||||
// 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept
|
// 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies the case: end To End, oldest Partition, purged Via Actor, newer Kept.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept()
|
public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept()
|
||||||
{
|
{
|
||||||
@@ -283,7 +287,7 @@ SELECT
|
|||||||
|
|
||||||
var actor = CreateActor(sp, purgeOptions, auditOptions);
|
var actor = CreateActor(sp, purgeOptions, auditOptions);
|
||||||
|
|
||||||
// The Jan-2026 partition boundary is the only eligible one in this fixture DB.
|
// The January 2026 partition boundary is the only eligible one in this fixture DB.
|
||||||
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
|
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
|
||||||
isMessage: m => m.MonthBoundary == janBoundary,
|
isMessage: m => m.MonthBoundary == janBoundary,
|
||||||
@@ -313,14 +317,16 @@ SELECT
|
|||||||
// 2. EndToEnd_AlignedUniqueness_AfterPurge
|
// 2. EndToEnd_AlignedUniqueness_AfterPurge
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies the case: end To End, aligned Uniqueness, after Purge.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_AlignedUniqueness_AfterPurge()
|
public async Task EndToEnd_AlignedUniqueness_AfterPurge()
|
||||||
{
|
{
|
||||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
// Same shape as test 1 — purge the Jan-2026 partition and then assert the
|
// Same shape as test 1 — purge the January 2026 partition and then assert the
|
||||||
// index state. RetentionDays is computed dynamically so the threshold always
|
// index state. RetentionDays is computed dynamically so the threshold always
|
||||||
// lands near 2026-01-20 (see SeedOccurredAt()). Since WP2.2 the switch touches
|
// lands near 2026-01-20 (see SeedOccurredAt()). The switch touches
|
||||||
// no index at all: uniqueness rides the aligned clustered PK, so there is
|
// no index at all: uniqueness rides the aligned clustered PK, so there is
|
||||||
// nothing to drop before the SWITCH and nothing to rebuild after it.
|
// nothing to drop before the SWITCH and nothing to rebuild after it.
|
||||||
var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
@@ -370,14 +376,16 @@ SELECT
|
|||||||
// 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge
|
// 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies the case: end To End, insert If Not Exists Async, still Idempotent, after Purge.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge()
|
public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge()
|
||||||
{
|
{
|
||||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
// Seed + purge the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for
|
// Seed + purge the January 2026 row, THEN exercise InsertIfNotExistsAsync twice for
|
||||||
// a fresh recent EventId. The second call must be a no-op (duplicate-key collision
|
// a fresh recent EventId. The second call must be a no-op (duplicate-key collision
|
||||||
// swallowed by the repository, per M2 Bundle A's race-fix) — which means the
|
// swallowed by the repository, per the repository's race fix) — which means the
|
||||||
// aligned clustered PK is still enforcing uniqueness as intended.
|
// aligned clustered PK is still enforcing uniqueness as intended.
|
||||||
// RetentionDays is computed dynamically so the threshold always lands near
|
// RetentionDays is computed dynamically so the threshold always lands near
|
||||||
// 2026-01-20 (see SeedOccurredAt()).
|
// 2026-01-20 (see SeedOccurredAt()).
|
||||||
@@ -455,11 +463,11 @@ SELECT
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3)
|
// 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// M5.5 (T3): exercises <see cref="IAuditLogRepository.PurgeChannelOlderThanAsync"/>
|
/// Exercises <see cref="IAuditLogRepository.PurgeChannelOlderThanAsync"/>
|
||||||
/// directly against the real repository + fixture DB. Seeds, in the SAME partition,
|
/// directly against the real repository + fixture DB. Seeds, in the SAME partition,
|
||||||
/// old + recent rows for an OVERRIDDEN channel (<c>ApiOutbound</c>) and old + recent
|
/// old + recent rows for an OVERRIDDEN channel (<c>ApiOutbound</c>) and old + recent
|
||||||
/// rows for an UN-overridden channel (<c>DbOutbound</c>), then runs the per-channel
|
/// rows for an UN-overridden channel (<c>DbOutbound</c>), then runs the per-channel
|
||||||
@@ -474,6 +482,7 @@ SELECT
|
|||||||
/// the append-only writer-role DENYs do not bind (the role granularity is exercised
|
/// the append-only writer-role DENYs do not bind (the role granularity is exercised
|
||||||
/// in the repository/migration tests).
|
/// in the repository/migration tests).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows()
|
public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows()
|
||||||
{
|
{
|
||||||
@@ -538,17 +547,18 @@ SELECT
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 5. SwitchOut_WithExplicitCommandTimeout_Succeeds (Task 4)
|
// 5. SwitchOut_WithExplicitCommandTimeout_Succeeds
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Task 4 (arch-review 04, S2): proves the explicit-<see cref="TimeSpan"/> maintenance-timeout
|
/// Proves the explicit-<see cref="TimeSpan"/> maintenance-timeout
|
||||||
/// overload of <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> runs the real
|
/// overload of <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> runs the real
|
||||||
/// staging/switch batch to completion against SQL Server — the old row is purged, the kept row
|
/// staging/switch batch to completion against SQL Server — the old row is purged, the kept row
|
||||||
/// survives, and the returned sampled row-count reflects the switched partition. Exercising the
|
/// survives, and the returned sampled row-count reflects the switched partition. Exercising the
|
||||||
/// timeout path end-to-end guards against a regression that only sets the timeout on the sample
|
/// timeout path end-to-end guards against a regression that only sets the timeout on the sample
|
||||||
/// command and forgets the DDL batch (or vice versa).
|
/// command and forgets the DDL batch (or vice versa).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task SwitchOut_WithExplicitCommandTimeout_Succeeds()
|
public async Task SwitchOut_WithExplicitCommandTimeout_Succeeds()
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-2
@@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle H — end-to-end test wiring the full Audit Log #23 M2 sync-call pipeline:
|
/// End-to-end test wiring the full sync-call audit pipeline:
|
||||||
/// <see cref="FallbackAuditWriter"/> over a <see cref="SqliteAuditWriter"/> backed by
|
/// <see cref="FallbackAuditWriter"/> over a <see cref="SqliteAuditWriter"/> backed by
|
||||||
/// an in-memory SQLite database; the <see cref="SiteAuditTelemetryActor"/> drains
|
/// an in-memory SQLite database; the <see cref="SiteAuditTelemetryActor"/> drains
|
||||||
/// Pending rows and pushes them through a stub <see cref="ISiteStreamAuditClient"/>
|
/// Pending rows and pushes them through a stub <see cref="ISiteStreamAuditClient"/>
|
||||||
@@ -30,7 +30,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This is a <b>component-level</b> integration test, not a full Akka-cluster
|
/// This is a <b>component-level</b> integration test, not a full Akka-cluster
|
||||||
/// test (per the M2 brainstorm decision). The stub gRPC client short-circuits
|
/// test. The stub gRPC client short-circuits
|
||||||
/// the wire so we exercise the real telemetry actor, the real ingest actor, the
|
/// the wire so we exercise the real telemetry actor, the real ingest actor, the
|
||||||
/// real SQLite writer, and the real MSSQL repository — without standing up a
|
/// real SQLite writer, and the real MSSQL repository — without standing up a
|
||||||
/// Kestrel host or two-cluster topology.
|
/// Kestrel host or two-cluster topology.
|
||||||
@@ -51,6 +51,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
{
|
{
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test with the shared MSSQL migration fixture.</summary>
|
||||||
|
/// <param name="fixture">The MSSQL fixture that supplies the central database.</param>
|
||||||
public SyncCallEmissionEndToEndTests(MsSqlMigrationFixture fixture)
|
public SyncCallEmissionEndToEndTests(MsSqlMigrationFixture fixture)
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
_fixture = fixture;
|
||||||
@@ -129,6 +131,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
FastTelemetryOptions(),
|
FastTelemetryOptions(),
|
||||||
NullLogger<SiteAuditTelemetryActor>.Instance)));
|
NullLogger<SiteAuditTelemetryActor>.Instance)));
|
||||||
|
|
||||||
|
/// <summary>Verifies that one written audit event reaches the central AuditLog within a reasonable time.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_OneWrittenEvent_Reaches_Central_AuditLog_Within_Reasonable_Time()
|
public async Task EndToEnd_OneWrittenEvent_Reaches_Central_AuditLog_Within_Reasonable_Time()
|
||||||
{
|
{
|
||||||
@@ -174,6 +178,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a gRPC stub error leaves the row pending and the next tick delivers it successfully.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds()
|
public async Task EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds()
|
||||||
{
|
{
|
||||||
@@ -246,6 +252,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that submitting the same event twice results in only one central row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task EndToEnd_DuplicateSubmit_OnlyOneCentralRow()
|
public async Task EndToEnd_DuplicateSubmit_OnlyOneCentralRow()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Kpi;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Kpi;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// M6 "KPI History & 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
|
/// the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi.IKpiSampleSource"/> the
|
||||||
/// central recorder enumerates to snapshot Audit Log KPIs. Asserts the source maps the
|
/// central recorder enumerates to snapshot Audit Log KPIs. Asserts the source maps the
|
||||||
/// repository's <see cref="AuditLogKpiSnapshot"/> onto exactly three Global-scope samples
|
/// repository's <see cref="AuditLogKpiSnapshot"/> onto exactly three Global-scope samples
|
||||||
@@ -19,6 +19,7 @@ public class AuditLogKpiSampleSourceTests
|
|||||||
{
|
{
|
||||||
private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 30, 0, DateTimeKind.Utc);
|
private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>Verifies that the sample source reports the AuditLog source name.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Source_is_AuditLog()
|
public void Source_is_AuditLog()
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,8 @@ public class AuditLogKpiSampleSourceTests
|
|||||||
Assert.Equal(KpiSources.AuditLog, sut.Source);
|
Assert.Equal(KpiSources.AuditLog, sut.Source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that collecting emits three global samples mapped from the health snapshot.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_emits_three_global_samples_mapped_from_snapshot()
|
public async Task CollectAsync_emits_three_global_samples_mapped_from_snapshot()
|
||||||
{
|
{
|
||||||
@@ -64,6 +67,8 @@ public class AuditLogKpiSampleSourceTests
|
|||||||
AssertMetric(samples, "backlogTotal", 305);
|
AssertMetric(samples, "backlogTotal", 305);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the sample window is anchored on the snapshot's capture time.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_anchors_the_window_on_capturedAtUtc()
|
public async Task CollectAsync_anchors_the_window_on_capturedAtUtc()
|
||||||
{
|
{
|
||||||
@@ -83,6 +88,8 @@ public class AuditLogKpiSampleSourceTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that collecting returns no samples when the snapshot is null.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_returns_empty_when_snapshot_is_null()
|
public async Task CollectAsync_returns_empty_when_snapshot_is_null()
|
||||||
{
|
{
|
||||||
@@ -98,6 +105,8 @@ public class AuditLogKpiSampleSourceTests
|
|||||||
Assert.Empty(samples);
|
Assert.Empty(samples);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the backlog provider's value is used when one is registered.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_uses_backlog_provider_when_present()
|
public async Task CollectAsync_uses_backlog_provider_when_present()
|
||||||
{
|
{
|
||||||
@@ -127,11 +136,13 @@ public class AuditLogKpiSampleSourceTests
|
|||||||
AssertMetric(samples, "backlogTotal", 42);
|
AssertMetric(samples, "backlogTotal", 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the snapshot's own backlog total is used when no backlog provider is registered.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_falls_back_to_snapshot_backlog_when_provider_absent()
|
public async Task CollectAsync_falls_back_to_snapshot_backlog_when_provider_absent()
|
||||||
{
|
{
|
||||||
// No provider (sites / unit tests): the sample source degrades to the
|
// No provider (sites / unit tests): the sample source degrades to the
|
||||||
// snapshot's own BacklogTotal — unchanged pre-Task-10 behavior.
|
// snapshot's own BacklogTotal — unchanged behavior from before the provider existed.
|
||||||
var snapshot = new AuditLogKpiSnapshot(
|
var snapshot = new AuditLogKpiSnapshot(
|
||||||
TotalEventsLastHour: 10,
|
TotalEventsLastHour: 10,
|
||||||
ErrorEventsLastHour: 1,
|
ErrorEventsLastHour: 1,
|
||||||
|
|||||||
+27
-2
@@ -10,8 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Guards the fail-closed contract for unavailable redactor patterns
|
/// Guards the fail-closed contract for unavailable redactor patterns.
|
||||||
/// (Gitea #35).
|
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The defect.</b> <see cref="AuditRegexCache"/> rejects a pattern that is
|
/// <b>The defect.</b> <see cref="AuditRegexCache"/> rejects a pattern that is
|
||||||
@@ -118,6 +117,7 @@ public class AuditRedactorFailClosedTests
|
|||||||
Assert.Equal(0, counter.Count);
|
Assert.Equal(0, counter.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unavailable global body redactor suppresses the payload rather than emitting it raw.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UnavailableGlobalBodyRedactor_SuppressesPayload_RatherThanEmittingItRaw()
|
public void UnavailableGlobalBodyRedactor_SuppressesPayload_RatherThanEmittingItRaw()
|
||||||
{
|
{
|
||||||
@@ -152,6 +152,7 @@ public class AuditRedactorFailClosedTests
|
|||||||
Assert.Equal(1, counter.Count);
|
Assert.Equal(1, counter.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unavailable per-target body redactor suppresses the payload.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UnavailablePerTargetBodyRedactor_SuppressesPayload()
|
public void UnavailablePerTargetBodyRedactor_SuppressesPayload()
|
||||||
{
|
{
|
||||||
@@ -267,16 +268,27 @@ public class AuditRedactorFailClosedTests
|
|||||||
private sealed class CountingCounter : IAuditRedactionFailureCounter
|
private sealed class CountingCounter : IAuditRedactionFailureCounter
|
||||||
{
|
{
|
||||||
private int _count;
|
private int _count;
|
||||||
|
/// <summary>Gets the number of redaction failures counted so far.</summary>
|
||||||
public int Count => Volatile.Read(ref _count);
|
public int Count => Volatile.Read(ref _count);
|
||||||
|
/// <inheritdoc />
|
||||||
public void Increment() => Interlocked.Increment(ref _count);
|
public void Increment() => Interlocked.Increment(ref _count);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||||
{
|
{
|
||||||
private readonly AuditLogOptions _value;
|
private readonly AuditLogOptions _value;
|
||||||
|
/// <summary>Initializes the monitor with a fixed options value.</summary>
|
||||||
|
/// <param name="value">The value.</param>
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
public StaticMonitor(AuditLogOptions value) => _value = value;
|
||||||
|
/// <summary>Gets the current options value.</summary>
|
||||||
public AuditLogOptions CurrentValue => _value;
|
public AuditLogOptions CurrentValue => _value;
|
||||||
|
/// <summary>Returns the current options value regardless of the name.</summary>
|
||||||
|
/// <param name="name">The options name; ignored.</param>
|
||||||
|
/// <returns>The current options value.</returns>
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
public AuditLogOptions Get(string? name) => _value;
|
||||||
|
/// <summary>Registers a listener to be notified when the options change.</summary>
|
||||||
|
/// <param name="listener">The change callback.</param>
|
||||||
|
/// <returns>A disposable that removes the listener, or null when none is tracked.</returns>
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,10 +298,14 @@ public class AuditRedactorFailClosedTests
|
|||||||
private readonly List<Action<AuditLogOptions, string?>> _listeners = new();
|
private readonly List<Action<AuditLogOptions, string?>> _listeners = new();
|
||||||
private int _reads;
|
private int _reads;
|
||||||
|
|
||||||
|
/// <summary>Initializes the monitor with an initial options value.</summary>
|
||||||
|
/// <param name="value">The value.</param>
|
||||||
public MutableMonitor(AuditLogOptions value) => _value = value;
|
public MutableMonitor(AuditLogOptions value) => _value = value;
|
||||||
|
|
||||||
|
/// <summary>Gets the number of times <c>CurrentValue</c> has been read.</summary>
|
||||||
public int CurrentValueReads => Volatile.Read(ref _reads);
|
public int CurrentValueReads => Volatile.Read(ref _reads);
|
||||||
|
|
||||||
|
/// <summary>Gets the current options value.</summary>
|
||||||
public AuditLogOptions CurrentValue
|
public AuditLogOptions CurrentValue
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -299,8 +315,14 @@ public class AuditRedactorFailClosedTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the current options value.</summary>
|
||||||
|
/// <param name="name">The options name (ignored).</param>
|
||||||
|
/// <returns>The current options value.</returns>
|
||||||
public AuditLogOptions Get(string? name) => CurrentValue;
|
public AuditLogOptions Get(string? name) => CurrentValue;
|
||||||
|
|
||||||
|
/// <summary>Registers a listener to be notified when the options change.</summary>
|
||||||
|
/// <param name="listener">The change callback.</param>
|
||||||
|
/// <returns>A disposable that removes the listener, or null when none is tracked.</returns>
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener)
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener)
|
||||||
{
|
{
|
||||||
lock (_listeners)
|
lock (_listeners)
|
||||||
@@ -310,6 +332,8 @@ public class AuditRedactorFailClosedTests
|
|||||||
return new Noop();
|
return new Noop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces the current options value and notifies every registered listener.</summary>
|
||||||
|
/// <param name="value">The value.</param>
|
||||||
public void Set(AuditLogOptions value)
|
public void Set(AuditLogOptions value)
|
||||||
{
|
{
|
||||||
_value = value;
|
_value = value;
|
||||||
@@ -326,6 +350,7 @@ public class AuditRedactorFailClosedTests
|
|||||||
|
|
||||||
private sealed class Noop : IDisposable
|
private sealed class Noop : IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for
|
/// Tests for
|
||||||
/// <see cref="SafeDefaultAuditRedactor"/> — the minimal always-safe
|
/// <see cref="SafeDefaultAuditRedactor"/> — the minimal always-safe
|
||||||
/// <see cref="ZB.MOM.WW.Audit.IAuditRedactor"/> fallback.
|
/// <see cref="ZB.MOM.WW.Audit.IAuditRedactor"/> fallback.
|
||||||
/// Header-only scrub of the always-sensitive default headers inside
|
/// Header-only scrub of the always-sensitive default headers inside
|
||||||
@@ -35,6 +35,7 @@ public class SafeDefaultAuditRedactorTests
|
|||||||
private static AuditDetails Details(AuditEvent evt) =>
|
private static AuditDetails Details(AuditEvent evt) =>
|
||||||
AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default sensitive headers are redacted in the request summary.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Redacts_DefaultSensitiveHeaders_InRequestSummary()
|
public void Redacts_DefaultSensitiveHeaders_InRequestSummary()
|
||||||
{
|
{
|
||||||
@@ -48,6 +49,7 @@ public class SafeDefaultAuditRedactorTests
|
|||||||
Assert.Contains("Content-Type: application/json", d.RequestSummary!);
|
Assert.Contains("Content-Type: application/json", d.RequestSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default sensitive headers are redacted in the response summary.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Redacts_DefaultSensitiveHeaders_InResponseSummary()
|
public void Redacts_DefaultSensitiveHeaders_InResponseSummary()
|
||||||
{
|
{
|
||||||
@@ -61,6 +63,7 @@ public class SafeDefaultAuditRedactorTests
|
|||||||
Assert.Contains("X-Other: ok", d.ResponseSummary!);
|
Assert.Contains("X-Other: ok", d.ResponseSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that header names are matched case-insensitively when redacting.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CaseInsensitive_HeaderName_Redacted()
|
public void CaseInsensitive_HeaderName_Redacted()
|
||||||
{
|
{
|
||||||
@@ -72,6 +75,7 @@ public class SafeDefaultAuditRedactorTests
|
|||||||
Assert.DoesNotContain("x-y-z", Details(result).RequestSummary!);
|
Assert.DoesNotContain("x-y-z", Details(result).RequestSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a non-sensitive header is preserved unchanged.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NonSensitiveHeader_Preserved()
|
public void NonSensitiveHeader_Preserved()
|
||||||
{
|
{
|
||||||
@@ -84,6 +88,7 @@ public class SafeDefaultAuditRedactorTests
|
|||||||
Assert.Contains("Accept: application/json", d.RequestSummary!);
|
Assert.Contains("Accept: application/json", d.RequestSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an event with null details takes the fast path and returns the same instance.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NullDetails_FastPath_ReturnsSameInstance()
|
public void NullDetails_FastPath_ReturnsSameInstance()
|
||||||
{
|
{
|
||||||
@@ -102,6 +107,7 @@ public class SafeDefaultAuditRedactorTests
|
|||||||
Assert.Same(evt, result);
|
Assert.Same(evt, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that malformed details JSON never causes the redactor to throw.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MalformedDetailsJson_NeverThrows()
|
public void MalformedDetailsJson_NeverThrows()
|
||||||
{
|
{
|
||||||
|
|||||||
+49
-1
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for
|
/// ScadaBridge audit re-architecture stage C2 tests for
|
||||||
/// <see cref="ScadaBridgeAuditRedactor"/> — the canonical
|
/// <see cref="ScadaBridgeAuditRedactor"/> — the canonical
|
||||||
/// <see cref="IAuditRedactor"/> implementation. Covers the header-redaction /
|
/// <see cref="IAuditRedactor"/> implementation. Covers the header-redaction /
|
||||||
/// body-regex / SQL-param / safety-net / truncation pipeline operating on
|
/// body-regex / SQL-param / safety-net / truncation pipeline operating on
|
||||||
@@ -20,6 +20,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ScadaBridgeAuditRedactorTests
|
public class ScadaBridgeAuditRedactorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that every field of an error row is redacted and the error message is capped, whether the redactor is configured globally or per target.</summary>
|
||||||
|
/// <param name="perTarget">True to configure the redactor through a per-target override; false to configure it globally.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(false)]
|
[InlineData(false)]
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
@@ -35,6 +37,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.True(Encoding.UTF8.GetByteCount(Details(result).ErrorMessage!) <= 128);
|
Assert.True(Encoding.UTF8.GetByteCount(Details(result).ErrorMessage!) <= 128);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the SQL parameter policy does not persist values echoed back in an exception message.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues()
|
public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues()
|
||||||
{
|
{
|
||||||
@@ -101,6 +104,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
|
|
||||||
// ---- Header redaction (ports HeaderRedactionTests) ---------------------
|
// ---- Header redaction (ports HeaderRedactionTests) ---------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that an Authorization bearer header is redacted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HeaderRedaction_AuthorizationBearer_Redacted()
|
public void HeaderRedaction_AuthorizationBearer_Redacted()
|
||||||
{
|
{
|
||||||
@@ -116,6 +120,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Contains("application/json", d.RequestSummary);
|
Assert.Contains("application/json", d.RequestSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that header redaction is case-insensitive, so a lowercase authorization header is redacted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
|
public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
|
||||||
{
|
{
|
||||||
@@ -127,6 +132,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Contains("\"authorization\":\"<redacted>\"", Details(result).RequestSummary!);
|
Assert.Contains("\"authorization\":\"<redacted>\"", Details(result).RequestSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a custom redact list redacts a custom header name.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
|
public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
|
||||||
{
|
{
|
||||||
@@ -141,6 +147,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Contains("Bearer keep-me", d.RequestSummary!);
|
Assert.Contains("Bearer keep-me", d.RequestSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a request summary that is not JSON passes through header redaction unchanged.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
|
public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
|
||||||
{
|
{
|
||||||
@@ -153,6 +160,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
|
|
||||||
// ---- Body regex redaction (ports BodyRegexRedactionTests) --------------
|
// ---- Body regex redaction (ports BodyRegexRedactionTests) --------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the global regex redacts a Hunter-style password value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GlobalRegex_HunterPassword_Redacted()
|
public void GlobalRegex_HunterPassword_Redacted()
|
||||||
{
|
{
|
||||||
@@ -170,6 +178,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Contains("alice", d.RequestSummary!);
|
Assert.Contains("alice", d.RequestSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the body regex is applied to the error detail and the extra field.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BodyRegex_AppliesToErrorDetailAndExtra()
|
public void BodyRegex_AppliesToErrorDetailAndExtra()
|
||||||
{
|
{
|
||||||
@@ -190,6 +199,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Contains("<redacted>", d.Extra!);
|
Assert.Contains("<redacted>", d.Extra!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a per-target regex is applied only to the matching target.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PerTargetRegex_OnlyAppliedToMatchingTarget()
|
public void PerTargetRegex_OnlyAppliedToMatchingTarget()
|
||||||
{
|
{
|
||||||
@@ -213,6 +223,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Equal(input, Details(unmatched).RequestSummary);
|
Assert.Equal(input, Details(unmatched).RequestSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a field is left unchanged when no regex is configured.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NoRegexConfigured_FieldUnchanged()
|
public void NoRegexConfigured_FieldUnchanged()
|
||||||
{
|
{
|
||||||
@@ -223,6 +234,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary);
|
Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a regex that times out turns the field into the redacted marker and increments the failure counter.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
|
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
|
||||||
{
|
{
|
||||||
@@ -257,6 +269,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that SQL parameters are kept verbatim when no opt-in is configured.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NoOptIn_ParamsVerbatim_Unchanged()
|
public void NoOptIn_ParamsVerbatim_Unchanged()
|
||||||
{
|
{
|
||||||
@@ -272,6 +285,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Equal(input, Details(result).RequestSummary);
|
Assert.Equal(input, Details(result).RequestSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an opt-in regex on parameter names redacts the matching values and keeps the others.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers()
|
public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers()
|
||||||
{
|
{
|
||||||
@@ -298,6 +312,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.DoesNotContain("k-987", d.RequestSummary!);
|
Assert.DoesNotContain("k-987", d.RequestSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a channel other than database-outbound is not affected by the SQL parameter policy.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NonDbOutboundChannel_NotAffected()
|
public void NonDbOutboundChannel_NotAffected()
|
||||||
{
|
{
|
||||||
@@ -320,6 +335,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
|
|
||||||
// ---- Truncation + cap selection (ports TruncationTests) ----------------
|
// ---- Truncation + cap selection (ports TruncationTests) ----------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a 10 KB request summary on a success row is truncated to 8 KB and flagged as payload-truncated.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
|
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
|
||||||
{
|
{
|
||||||
@@ -334,6 +350,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.True(d.PayloadTruncated);
|
Assert.True(d.PayloadTruncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a 10 KB request summary on a failed row is not truncated because it is under the error cap.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap()
|
public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap()
|
||||||
{
|
{
|
||||||
@@ -348,6 +365,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.False(d.PayloadTruncated);
|
Assert.False(d.PayloadTruncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a 70 KB request summary on a failed row is truncated to 64 KB.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB()
|
public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB()
|
||||||
{
|
{
|
||||||
@@ -362,6 +380,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.True(d.PayloadTruncated);
|
Assert.True(d.PayloadTruncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an attempted status is treated as an error and uses the error cap even though the outcome is success.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
|
public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
|
||||||
{
|
{
|
||||||
@@ -379,6 +398,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.False(d.PayloadTruncated);
|
Assert.False(d.PayloadTruncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a skipped status is treated as an error and uses the error cap even though the outcome is success.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
|
public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
|
||||||
{
|
{
|
||||||
@@ -391,6 +411,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.False(Details(result).PayloadTruncated);
|
Assert.False(Details(result).PayloadTruncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a submitted status is treated as a success and uses the default cap.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap()
|
public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap()
|
||||||
{
|
{
|
||||||
@@ -406,6 +427,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.True(d.PayloadTruncated);
|
Assert.True(d.PayloadTruncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a large inbound API body uses the inbound cap rather than the default cap.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ApiInbound_LargeBody_UsesInboundCap_NotDefault()
|
public void ApiInbound_LargeBody_UsesInboundCap_NotDefault()
|
||||||
{
|
{
|
||||||
@@ -420,6 +442,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!));
|
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a failed inbound API body above the inbound maximum is truncated to that maximum.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
|
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
|
||||||
{
|
{
|
||||||
@@ -435,6 +458,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384);
|
Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that multibyte UTF-8 text is truncated at a character boundary rather than in the middle of a byte sequence.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
|
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
|
||||||
{
|
{
|
||||||
@@ -453,6 +477,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.True(d.PayloadTruncated);
|
Assert.True(d.PayloadTruncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a details payload that was already marked truncated stays truncated.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ExistingDetailsPayloadTruncated_RemainsTrue()
|
public void ExistingDetailsPayloadTruncated_RemainsTrue()
|
||||||
{
|
{
|
||||||
@@ -467,6 +492,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
|
|
||||||
// ---- Target length cap -------------------------------------------------
|
// ---- Target length cap -------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a target over the cap is truncated at a byte boundary.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Target_OverCap_Truncated_ToByteBoundary()
|
public void Target_OverCap_Truncated_ToByteBoundary()
|
||||||
{
|
{
|
||||||
@@ -483,6 +509,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
|
|
||||||
// ---- Fast-path ---------------------------------------------------------
|
// ---- Fast-path ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the fast path returns the same instance when the details are null and the target is short.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance()
|
public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance()
|
||||||
{
|
{
|
||||||
@@ -502,6 +529,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Same(evt, result);
|
Assert.Same(evt, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the fast path returns the same instance when the details are empty and the target is short.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance()
|
public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance()
|
||||||
{
|
{
|
||||||
@@ -523,6 +551,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
|
|
||||||
// ---- Never-throws safety net -------------------------------------------
|
// ---- Never-throws safety net -------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that malformed details JSON never throws and yields a safe copy.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy()
|
public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy()
|
||||||
{
|
{
|
||||||
@@ -545,6 +574,7 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
Assert.Null(ex);
|
Assert.Null(ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a fault thrown by the options monitor never leaks sensitive values, because the outer catch over-redacts every sensitive field.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted()
|
public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted()
|
||||||
{
|
{
|
||||||
@@ -601,7 +631,9 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
|
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
|
||||||
{
|
{
|
||||||
private int _count;
|
private int _count;
|
||||||
|
/// <summary>Gets the number of recorded failures.</summary>
|
||||||
public int Count => _count;
|
public int Count => _count;
|
||||||
|
/// <inheritdoc />
|
||||||
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
|
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,9 +641,18 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||||
{
|
{
|
||||||
private readonly AuditLogOptions _value;
|
private readonly AuditLogOptions _value;
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="StaticMonitor"/> class.</summary>
|
||||||
|
/// <param name="value">The options snapshot returned on every read.</param>
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
public StaticMonitor(AuditLogOptions value) => _value = value;
|
||||||
|
/// <summary>Gets the options value this monitor serves.</summary>
|
||||||
public AuditLogOptions CurrentValue => _value;
|
public AuditLogOptions CurrentValue => _value;
|
||||||
|
/// <summary>Returns the options value regardless of name.</summary>
|
||||||
|
/// <param name="name">The options name (ignored).</param>
|
||||||
|
/// <returns>The options value.</returns>
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
public AuditLogOptions Get(string? name) => _value;
|
||||||
|
/// <summary>Ignores the listener; the options never change.</summary>
|
||||||
|
/// <param name="listener">The change listener (ignored).</param>
|
||||||
|
/// <returns>Always null.</returns>
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,9 +663,16 @@ public class ScadaBridgeAuditRedactorTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class ThrowingMonitor : IOptionsMonitor<AuditLogOptions>
|
private sealed class ThrowingMonitor : IOptionsMonitor<AuditLogOptions>
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the options value this monitor serves.</summary>
|
||||||
public AuditLogOptions CurrentValue =>
|
public AuditLogOptions CurrentValue =>
|
||||||
throw new InvalidOperationException("Simulated options fault for outer-catch test");
|
throw new InvalidOperationException("Simulated options fault for outer-catch test");
|
||||||
|
/// <summary>Returns the options value regardless of name.</summary>
|
||||||
|
/// <param name="name">The options name (ignored).</param>
|
||||||
|
/// <returns>The options value.</returns>
|
||||||
public AuditLogOptions Get(string? name) => new AuditLogOptions();
|
public AuditLogOptions Get(string? name) => new AuditLogOptions();
|
||||||
|
/// <summary>Ignores the listener; the options never change.</summary>
|
||||||
|
/// <param name="listener">The change listener (ignored).</param>
|
||||||
|
/// <returns>Always null.</returns>
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle B (M2-T4) tests for <see cref="FallbackAuditWriter"/> — composes the
|
/// Tests for <see cref="FallbackAuditWriter"/> — composes the
|
||||||
/// primary <see cref="SqliteAuditWriter"/>, the drop-oldest
|
/// primary <see cref="SqliteAuditWriter"/>, the drop-oldest
|
||||||
/// <see cref="RingBufferFallback"/>, and an
|
/// <see cref="RingBufferFallback"/>, and an
|
||||||
/// <see cref="IAuditWriteFailureCounter"/> health counter.
|
/// <see cref="IAuditWriteFailureCounter"/> health counter.
|
||||||
@@ -28,9 +28,12 @@ public class FallbackAuditWriterTests
|
|||||||
/// <summary>Flip-switch primary writer mock.</summary>
|
/// <summary>Flip-switch primary writer mock.</summary>
|
||||||
private sealed class FlipSwitchPrimary : IAuditWriter
|
private sealed class FlipSwitchPrimary : IAuditWriter
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets or sets a value indicating whether the next write fails.</summary>
|
||||||
public bool FailNext { get; set; }
|
public bool FailNext { get; set; }
|
||||||
|
/// <summary>Gets the events the primary has accepted.</summary>
|
||||||
public List<AuditEvent> Written { get; } = new();
|
public List<AuditEvent> Written { get; } = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (FailNext)
|
if (FailNext)
|
||||||
@@ -42,6 +45,8 @@ public class FallbackAuditWriterTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that when the primary writer throws, the event lands in the ring buffer and the call still returns successfully.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess()
|
public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess()
|
||||||
{
|
{
|
||||||
@@ -59,6 +64,8 @@ public class FallbackAuditWriterTests
|
|||||||
counter.Received(1).Increment();
|
counter.Received(1).Increment();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that after the primary recovers, the ring buffer drains in FIFO order on the next write.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite()
|
public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite()
|
||||||
{
|
{
|
||||||
@@ -93,6 +100,8 @@ public class FallbackAuditWriterTests
|
|||||||
Assert.Equal("c", primary.Written[3].Target);
|
Assert.Equal("c", primary.Written[3].Target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the ring buffer stays empty when the primary writer always succeeds.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty()
|
public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty()
|
||||||
{
|
{
|
||||||
@@ -112,6 +121,8 @@ public class FallbackAuditWriterTests
|
|||||||
counter.DidNotReceive().Increment();
|
counter.DidNotReceive().Increment();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the failure counter is incremented once per primary failure.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure()
|
public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure()
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-2
@@ -5,17 +5,18 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle C (M5-T7) — the <see cref="HealthMetricsAuditRedactionFailureCounter"/>
|
/// The <see cref="HealthMetricsAuditRedactionFailureCounter"/>
|
||||||
/// adapter is the production binding for
|
/// adapter is the production binding for
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/> on
|
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/> on
|
||||||
/// site nodes; it forwards every <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
|
/// site nodes; it forwards every <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
|
||||||
/// redactor over-redaction event into the shared
|
/// redactor over-redaction event into the shared
|
||||||
/// <see cref="ISiteHealthCollector"/> so the site health report surfaces the
|
/// <see cref="ISiteHealthCollector"/> so the site health report surfaces the
|
||||||
/// count as <c>AuditRedactionFailure</c>. Mirrors the M2 Bundle G
|
/// count as <c>AuditRedactionFailure</c>. Mirrors the
|
||||||
/// HealthMetricsAuditWriteFailureCounter shape one-for-one.
|
/// HealthMetricsAuditWriteFailureCounter shape one-for-one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class HealthMetricsAuditRedactionFailureCounterTests
|
public class HealthMetricsAuditRedactionFailureCounterTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies the case: increment, routes, to, collector, increment Audit Redaction Failure.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure()
|
public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure()
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests
|
|||||||
collector.Received(1).IncrementAuditRedactionFailure();
|
collector.Received(1).IncrementAuditRedactionFailure();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the case: increment, multiple, calls, route, to, collector, each, time.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
|
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
|
||||||
{
|
{
|
||||||
@@ -40,6 +42,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests
|
|||||||
collector.Received(3).IncrementAuditRedactionFailure();
|
collector.Received(3).IncrementAuditRedactionFailure();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the case: construction, with, null, collector, throws, argument Null Exception.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
|
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
|
||||||
{
|
{
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle G (M2-T11) — the <see cref="HealthMetricsAuditWriteFailureCounter"/>
|
/// The <see cref="HealthMetricsAuditWriteFailureCounter"/>
|
||||||
/// adapter is the production binding for <see cref="IAuditWriteFailureCounter"/>
|
/// adapter is the production binding for <see cref="IAuditWriteFailureCounter"/>
|
||||||
/// on site nodes; it forwards every FallbackAuditWriter primary failure into
|
/// on site nodes; it forwards every FallbackAuditWriter primary failure into
|
||||||
/// the shared <see cref="ISiteHealthCollector"/> so the site health report
|
/// the shared <see cref="ISiteHealthCollector"/> so the site health report
|
||||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class HealthMetricsAuditWriteFailureCounterTests
|
public class HealthMetricsAuditWriteFailureCounterTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that a single increment is routed to the collector as a site audit write failure.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures()
|
public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures()
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,7 @@ public class HealthMetricsAuditWriteFailureCounterTests
|
|||||||
collector.Received(1).IncrementSiteAuditWriteFailures();
|
collector.Received(1).IncrementSiteAuditWriteFailures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that each of several increments is routed to the collector.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
|
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
|
||||||
{
|
{
|
||||||
@@ -37,6 +39,7 @@ public class HealthMetricsAuditWriteFailureCounterTests
|
|||||||
collector.Received(3).IncrementSiteAuditWriteFailures();
|
collector.Received(3).IncrementSiteAuditWriteFailures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that constructing the counter with a null collector throws an <see cref="ArgumentNullException"/>.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
|
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle B (M2-T3) tests for <see cref="RingBufferFallback"/> — the
|
/// Tests for <see cref="RingBufferFallback"/> — the
|
||||||
/// drop-oldest fallback used by <see cref="FallbackAuditWriter"/> when the
|
/// drop-oldest fallback used by <see cref="FallbackAuditWriter"/> when the
|
||||||
/// primary SQLite writer is throwing.
|
/// primary SQLite writer is throwing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -23,6 +23,8 @@ public class RingBufferFallbackTests
|
|||||||
target: target);
|
target: target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that enqueuing 1025 events into a ring with a capacity of 1024 drops the oldest event and raises the overflow notification exactly once.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce()
|
public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce()
|
||||||
{
|
{
|
||||||
@@ -51,6 +53,8 @@ public class RingBufferFallbackTests
|
|||||||
Assert.Equal("1024", drained[^1].Target);
|
Assert.Equal("1024", drained[^1].Target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that draining yields the queued events in FIFO order and then completes once the ring is empty.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty()
|
public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty()
|
||||||
{
|
{
|
||||||
@@ -76,6 +80,7 @@ public class RingBufferFallbackTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that enqueuing a single event into a ring with spare capacity succeeds and returns true.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryEnqueue_AllSucceeds_ReturnsTrue()
|
public void TryEnqueue_AllSucceeds_ReturnsTrue()
|
||||||
{
|
{
|
||||||
|
|||||||
+27
-2
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deferred-work #21: the backlog reporter's poll cadence is configurable via
|
/// The backlog reporter's poll cadence is configurable via
|
||||||
/// <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/> instead of the
|
/// <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/> instead of the
|
||||||
/// old hard-coded 30 s constant.
|
/// old hard-coded 30 s constant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -26,6 +26,8 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
explicitInterval,
|
explicitInterval,
|
||||||
options);
|
options);
|
||||||
|
|
||||||
|
/// <summary>Verifies that stopping the reporter while a probe is in flight lets the loop complete cleanly.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StopAsync_WhileProbeInFlight_LoopCompletesCleanly()
|
public async Task StopAsync_WhileProbeInFlight_LoopCompletesCleanly()
|
||||||
{
|
{
|
||||||
@@ -62,10 +64,12 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
throw new UnreachableException("the delay above always throws on cancellation");
|
throw new UnreachableException("the delay above always throws on cancellation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that stopping the reporter after it has been disposed does not throw.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
||||||
{
|
{
|
||||||
// Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync
|
// Regression (follow-up): Dispose tears down the CTS StopAsync
|
||||||
// cancels, and the host does not guarantee StopAsync is driven before the DI
|
// cancels, and the host does not guarantee StopAsync is driven before the DI
|
||||||
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
||||||
// escape an IHostedService aborts the host's whole shutdown sequence.
|
// escape an IHostedService aborts the host's whole shutdown sequence.
|
||||||
@@ -79,6 +83,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
reporter.Dispose();
|
reporter.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the poll cadence comes from the options when one is configured.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Cadence_ComesFromOptions_WhenConfigured()
|
public void Cadence_ComesFromOptions_WhenConfigured()
|
||||||
{
|
{
|
||||||
@@ -89,6 +94,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval);
|
Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a non-positive configured interval falls back to the default cadence.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Cadence_FallsBackToDefault_WhenOptionsNonPositive()
|
public void Cadence_FallsBackToDefault_WhenOptionsNonPositive()
|
||||||
{
|
{
|
||||||
@@ -99,6 +105,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default cadence is used when no options are supplied.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Cadence_FallsBackToDefault_WhenNoOptions()
|
public void Cadence_FallsBackToDefault_WhenNoOptions()
|
||||||
{
|
{
|
||||||
@@ -107,6 +114,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an explicitly supplied interval takes precedence over the options value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ExplicitInterval_WinsOverOptions()
|
public void ExplicitInterval_WinsOverOptions()
|
||||||
{
|
{
|
||||||
@@ -119,6 +127,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
|
|
||||||
// ----- Stale-Pending signal (review F5) ----- //
|
// ----- Stale-Pending signal (review F5) ----- //
|
||||||
|
|
||||||
|
/// <summary>Verifies that a stale pending backlog is logged as a warning and that repeat warnings are then rate-limited.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StalePendingBacklog_IsWarned_ThenRateLimited()
|
public void StalePendingBacklog_IsWarned_ThenRateLimited()
|
||||||
{
|
{
|
||||||
@@ -143,6 +152,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
Assert.Contains("pending", warning.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("pending", warning.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a fresh or empty pending backlog does not log a warning.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FreshOrEmptyPendingBacklog_IsNotWarned()
|
public void FreshOrEmptyPendingBacklog_IsNotWarned()
|
||||||
{
|
{
|
||||||
@@ -163,12 +173,27 @@ public class SiteAuditBacklogReporterCadenceTests
|
|||||||
/// <summary>Captures log entries so the stale-pending signal can be asserted.</summary>
|
/// <summary>Captures log entries so the stale-pending signal can be asserted.</summary>
|
||||||
private sealed class CapturingLogger : ILogger<SiteAuditBacklogReporter>
|
private sealed class CapturingLogger : ILogger<SiteAuditBacklogReporter>
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the log entries captured so far.</summary>
|
||||||
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Returns no scope; the fake logger does not track scopes.</summary>
|
||||||
|
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||||
|
/// <param name="state">The scope state, ignored.</param>
|
||||||
|
/// <returns>Always null or a no-op scope.</returns>
|
||||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
|
||||||
|
/// <summary>Reports every log level as enabled.</summary>
|
||||||
|
/// <param name="logLevel">The log level being queried.</param>
|
||||||
|
/// <returns>Always true.</returns>
|
||||||
public bool IsEnabled(LogLevel logLevel) => true;
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
/// <summary>Records the log entry for later assertions.</summary>
|
||||||
|
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||||
|
/// <param name="logLevel">The severity of the entry.</param>
|
||||||
|
/// <param name="eventId">The event identifier.</param>
|
||||||
|
/// <param name="state">The log state.</param>
|
||||||
|
/// <param name="exception">The associated exception, if any.</param>
|
||||||
|
/// <param name="formatter">Formats the state and exception into the message.</param>
|
||||||
public void Log<TState>(
|
public void Log<TState>(
|
||||||
LogLevel logLevel,
|
LogLevel logLevel,
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for <see cref="SiteAuditRetentionOptions"/> clamping (PLAN-04 Task 2).
|
/// Tests for <see cref="SiteAuditRetentionOptions"/> clamping.
|
||||||
/// The resolved knobs guard the daily site retention job against misconfiguration:
|
/// The resolved knobs guard the daily site retention job against misconfiguration:
|
||||||
/// retention days are clamped to [1, 90]; the purge interval is clamped away from
|
/// retention days are clamped to [1, 90]; the purge interval is clamped away from
|
||||||
/// TimeSpan.Zero (an Akka/Timer spin footgun).
|
/// TimeSpan.Zero (an Akka/Timer spin footgun).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteAuditRetentionOptionsTests
|
public class SiteAuditRetentionOptionsTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that resolved retention days, clamps.</summary>
|
||||||
|
/// <param name="configured">Test case input.</param>
|
||||||
|
/// <param name="expected">Test case input.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(0, 1)] // clamp floor
|
[InlineData(0, 1)] // clamp floor
|
||||||
[InlineData(7, 7)] // default passthrough
|
[InlineData(7, 7)] // default passthrough
|
||||||
@@ -17,6 +20,7 @@ public class SiteAuditRetentionOptionsTests
|
|||||||
public void ResolvedRetentionDays_Clamps(int configured, int expected)
|
public void ResolvedRetentionDays_Clamps(int configured, int expected)
|
||||||
=> Assert.Equal(expected, new SiteAuditRetentionOptions { RetentionDays = configured }.ResolvedRetentionDays);
|
=> Assert.Equal(expected, new SiteAuditRetentionOptions { RetentionDays = configured }.ResolvedRetentionDays);
|
||||||
|
|
||||||
|
/// <summary>Verifies that defaults, are, seven days, daily purge, five minute initial delay.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Defaults_Are_SevenDays_DailyPurge_FiveMinuteInitialDelay()
|
public void Defaults_Are_SevenDays_DailyPurge_FiveMinuteInitialDelay()
|
||||||
{
|
{
|
||||||
@@ -26,6 +30,7 @@ public class SiteAuditRetentionOptionsTests
|
|||||||
Assert.Equal(TimeSpan.FromMinutes(5), o.InitialDelay);
|
Assert.Equal(TimeSpan.FromMinutes(5), o.InitialDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that resolved purge interval, clamps zero to one minute.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolvedPurgeInterval_ClampsZeroToOneMinute()
|
public void ResolvedPurgeInterval_ClampsZeroToOneMinute()
|
||||||
=> Assert.Equal(TimeSpan.FromMinutes(1),
|
=> Assert.Equal(TimeSpan.FromMinutes(1),
|
||||||
|
|||||||
+4
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteAuditRetentionOptionsValidatorTests
|
public class SiteAuditRetentionOptionsValidatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the default options pass validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultOptions_AreValid()
|
public void DefaultOptions_AreValid()
|
||||||
{
|
{
|
||||||
@@ -19,6 +20,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded);
|
Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retention period of zero days fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroRetentionDays_Fails()
|
public void ZeroRetentionDays_Fails()
|
||||||
{
|
{
|
||||||
@@ -29,6 +31,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal));
|
f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a purge interval of zero fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroPurgeInterval_Fails()
|
public void ZeroPurgeInterval_Fails()
|
||||||
{
|
{
|
||||||
@@ -40,6 +43,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal));
|
f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a negative initial delay fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NegativeInitialDelay_Fails()
|
public void NegativeInitialDelay_Fails()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for <see cref="SiteAuditRetentionService"/> (PLAN-04 Task 3). The hosted
|
/// Tests for <see cref="SiteAuditRetentionService"/> The hosted
|
||||||
/// service runs the site SQLite retention purge on a timer: first tick after
|
/// service runs the site SQLite retention purge on a timer: first tick after
|
||||||
/// <see cref="SiteAuditRetentionOptions.InitialDelay"/>, then every
|
/// <see cref="SiteAuditRetentionOptions.InitialDelay"/>, then every
|
||||||
/// <see cref="SiteAuditRetentionOptions.ResolvedPurgeInterval"/>, passing a cutoff of
|
/// <see cref="SiteAuditRetentionOptions.ResolvedPurgeInterval"/>, passing a cutoff of
|
||||||
@@ -19,10 +19,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteAuditRetentionServiceTests
|
public class SiteAuditRetentionServiceTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that stopping the service after it has been disposed does not throw.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
||||||
{
|
{
|
||||||
// Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync
|
// Regression: Dispose tears down the CTS StopAsync
|
||||||
// cancels, and the host does not guarantee StopAsync is driven before the DI
|
// cancels, and the host does not guarantee StopAsync is driven before the DI
|
||||||
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
||||||
// escape an IHostedService aborts the host's whole shutdown sequence.
|
// escape an IHostedService aborts the host's whole shutdown sequence.
|
||||||
@@ -43,6 +45,8 @@ public class SiteAuditRetentionServiceTests
|
|||||||
svc.Dispose();
|
svc.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tick purges events older than the retention cutoff.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_Purges_With_RetentionCutoff()
|
public async Task Tick_Purges_With_RetentionCutoff()
|
||||||
{
|
{
|
||||||
@@ -66,6 +70,8 @@ public class SiteAuditRetentionServiceTests
|
|||||||
DateTime.UtcNow.AddDays(-7).AddMinutes(1));
|
DateTime.UtcNow.AddDays(-7).AddMinutes(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a failing tick is swallowed and later ticks still run.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_SwallowsExceptions_AndKeepsTicking()
|
public async Task Tick_SwallowsExceptions_AndKeepsTicking()
|
||||||
{
|
{
|
||||||
@@ -87,6 +93,8 @@ public class SiteAuditRetentionServiceTests
|
|||||||
Assert.True(queue.PurgeCalls.Count >= 2);
|
Assert.True(queue.PurgeCalls.Count >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that stopping the service during an in-flight purge completes cleanly without surfacing a cancellation.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation()
|
public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation()
|
||||||
{
|
{
|
||||||
@@ -127,6 +135,13 @@ public class SiteAuditRetentionServiceTests
|
|||||||
/// <summary>Stub queue that records purge cutoffs and can throw on the first tick.</summary>
|
/// <summary>Stub queue that records purge cutoffs and can throw on the first tick.</summary>
|
||||||
private sealed class RecordingSiteAuditQueue : ISiteAuditQueue
|
private sealed class RecordingSiteAuditQueue : ISiteAuditQueue
|
||||||
{
|
{
|
||||||
|
/// <summary>Not supported by this stub.</summary>
|
||||||
|
/// <param name="executionIds">The execution ids to read.</param>
|
||||||
|
/// <param name="sinceUtc">The lower time bound.</param>
|
||||||
|
/// <param name="limit">The maximum number of rows.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <param name="afterEventId">The keyset cursor.</param>
|
||||||
|
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
|
||||||
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
||||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
|
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
|
||||||
=> throw new NotSupportedException("not exercised by the retention service");
|
=> throw new NotSupportedException("not exercised by the retention service");
|
||||||
@@ -134,13 +149,17 @@ public class SiteAuditRetentionServiceTests
|
|||||||
private readonly ConcurrentQueue<DateTime> _purgeCalls = new();
|
private readonly ConcurrentQueue<DateTime> _purgeCalls = new();
|
||||||
private int _calls;
|
private int _calls;
|
||||||
|
|
||||||
|
/// <summary>Gets the cutoffs passed to each purge call, in call order.</summary>
|
||||||
public IReadOnlyCollection<DateTime> PurgeCalls => _purgeCalls;
|
public IReadOnlyCollection<DateTime> PurgeCalls => _purgeCalls;
|
||||||
|
/// <summary>Gets a value indicating whether the first purge call throws a simulated transient failure.</summary>
|
||||||
public bool ThrowOnFirstCall { get; init; }
|
public bool ThrowOnFirstCall { get; init; }
|
||||||
|
|
||||||
/// <summary>When set, PurgeExpiredAsync records the call then blocks until the token
|
/// <summary>When set, PurgeExpiredAsync records the call then blocks until the token
|
||||||
/// cancels — simulating a shutdown that lands while a purge is in flight.</summary>
|
/// cancels — simulating a shutdown that lands while a purge is in flight.</summary>
|
||||||
public bool BlockUntilCancelled { get; init; }
|
public bool BlockUntilCancelled { get; init; }
|
||||||
|
|
||||||
|
// Test stub: Records the cutoff and optionally throws or blocks, as configured.
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task<int> PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default)
|
public async Task<int> PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var n = Interlocked.Increment(ref _calls);
|
var n = Interlocked.Increment(ref _calls);
|
||||||
@@ -158,19 +177,41 @@ public class SiteAuditRetentionServiceTests
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Not supported by this stub.</summary>
|
||||||
|
/// <param name="limit">The maximum number of rows.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
|
||||||
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
|
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
/// <summary>Not supported by this stub.</summary>
|
||||||
|
/// <param name="limit">The maximum number of rows.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
|
||||||
public Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default)
|
public Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
// Test stub: Not supported by this stub.
|
||||||
|
/// <inheritdoc />
|
||||||
public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
|
public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
/// <summary>Not supported by this stub.</summary>
|
||||||
|
/// <param name="sinceUtc">The lower time bound.</param>
|
||||||
|
/// <param name="batchSize">The maximum batch size.</param>
|
||||||
|
/// <param name="afterId">The keyset cursor.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
|
||||||
public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
|
public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
|
||||||
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default)
|
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
// Test stub: Not supported by this stub.
|
||||||
|
/// <inheritdoc />
|
||||||
public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
|
public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
// Test stub: Not supported by this stub.
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<int> MarkReconciledUpToAsync(DateTime sinceUtc, string? afterId, CancellationToken ct = default)
|
public Task<int> MarkReconciledUpToAsync(DateTime sinceUtc, string? afterId, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
// Test stub: Not supported by this stub.
|
||||||
|
/// <inheritdoc />
|
||||||
public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default)
|
public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteAuditTelemetryOptionsValidatorTests
|
public class SiteAuditTelemetryOptionsValidatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that default options are valid.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultOptions_AreValid()
|
public void DefaultOptions_AreValid()
|
||||||
{
|
{
|
||||||
@@ -18,6 +19,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded);
|
Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that zero batch size fails.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroBatchSize_Fails()
|
public void ZeroBatchSize_Fails()
|
||||||
{
|
{
|
||||||
@@ -28,6 +30,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal));
|
f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that zero busy interval fails.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroBusyInterval_Fails()
|
public void ZeroBusyInterval_Fails()
|
||||||
{
|
{
|
||||||
@@ -38,6 +41,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal));
|
f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that zero idle interval fails.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroIdleInterval_Fails()
|
public void ZeroIdleInterval_Fails()
|
||||||
{
|
{
|
||||||
@@ -48,6 +52,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
|
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that idle shorter than busy fails.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IdleShorterThanBusy_Fails()
|
public void IdleShorterThanBusy_Fails()
|
||||||
{
|
{
|
||||||
|
|||||||
+14
-2
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle E (M6-T6) tests for <see cref="SqliteAuditWriter.GetBacklogStatsAsync"/>.
|
/// Tests for <see cref="SqliteAuditWriter.GetBacklogStatsAsync"/>.
|
||||||
/// Exercises the health-metric surface that <c>SiteAuditBacklogReporter</c>
|
/// Exercises the health-metric surface that <c>SiteAuditBacklogReporter</c>
|
||||||
/// polls every 30 s and pushes onto the site health report as
|
/// polls every 30 s and pushes onto the site health report as
|
||||||
/// <c>SiteAuditBacklog</c>.
|
/// <c>SiteAuditBacklog</c>.
|
||||||
@@ -18,6 +18,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly string _dbPath;
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
/// <summary>Creates a unique temporary database path for each test.</summary>
|
||||||
public SqliteAuditWriterBacklogStatsTests()
|
public SqliteAuditWriterBacklogStatsTests()
|
||||||
{
|
{
|
||||||
// OnDiskBytes assertions only make sense against a real file — the
|
// OnDiskBytes assertions only make sense against a real file — the
|
||||||
@@ -28,6 +29,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
|||||||
$"audit-backlog-stats-{Guid.NewGuid():N}.db");
|
$"audit-backlog-stats-{Guid.NewGuid():N}.db");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the resources held by this test class.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (File.Exists(_dbPath))
|
if (File.Exists(_dbPath))
|
||||||
@@ -52,6 +54,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
|||||||
kind: AuditKind.ApiCall,
|
kind: AuditKind.ApiCall,
|
||||||
status: AuditStatus.Delivered);
|
status: AuditStatus.Delivered);
|
||||||
|
|
||||||
|
/// <summary>Verifies that an empty database reports zero pending events, no oldest-pending timestamp and zero bytes on disk.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
|
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
|
||||||
{
|
{
|
||||||
@@ -70,6 +74,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
|||||||
$"OnDiskBytes must be non-negative, got {snapshot.OnDiskBytes}");
|
$"OnDiskBytes must be non-negative, got {snapshot.OnDiskBytes}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that five pending events are reported as a pending count of five.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Pending_5_Returns_5()
|
public async Task Pending_5_Returns_5()
|
||||||
{
|
{
|
||||||
@@ -85,6 +91,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
|||||||
Assert.Equal(5, snapshot.PendingCount);
|
Assert.Equal(5, snapshot.PendingCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the oldest-pending timestamp is the earliest <c>OccurredAtUtc</c> among the pending events.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OldestPending_Is_Earliest_OccurredAtUtc()
|
public async Task OldestPending_Is_Earliest_OccurredAtUtc()
|
||||||
{
|
{
|
||||||
@@ -110,10 +118,12 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
|||||||
Assert.Equal(t1, snapshot.OldestPendingUtc!.Value);
|
Assert.Equal(t1, snapshot.OldestPendingUtc!.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that reading the backlog stats does not block while a concurrent write load is running.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad()
|
public async Task GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad()
|
||||||
{
|
{
|
||||||
// AuditLog-005: GetBacklogStatsAsync previously took _writeLock, the
|
// GetBacklogStatsAsync previously took _writeLock, the
|
||||||
// same lock that serialises every batch INSERT in FlushBatch. Under a
|
// same lock that serialises every batch INSERT in FlushBatch. Under a
|
||||||
// backlog growing to hundreds of thousands of rows a COUNT(*)+MIN
|
// backlog growing to hundreds of thousands of rows a COUNT(*)+MIN
|
||||||
// index scan could park the hot-path writer for hundreds of ms. The
|
// index scan could park the hot-path writer for hundreds of ms. The
|
||||||
@@ -163,6 +173,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
|||||||
$"backlog probe should see at least the seeded rows; got {snapshot.PendingCount}");
|
$"backlog probe should see at least the seeded rows; got {snapshot.PendingCount}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the on-disk byte count matches the size of the database file.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnDiskBytes_ReturnsFileSize()
|
public async Task OnDiskBytes_ReturnsFileSize()
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-1
@@ -14,7 +14,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// arch-review 2026-08-19 P4-2 regression coverage. <see cref="SqliteAuditWriter.FlushBatch"/>
|
/// Regression coverage. <see cref="SqliteAuditWriter.FlushBatch"/>
|
||||||
/// used to call <c>pending.Completion.TrySetResult()</c> per row BEFORE
|
/// used to call <c>pending.Completion.TrySetResult()</c> per row BEFORE
|
||||||
/// <c>transaction.Commit()</c> ran. A later failure in the SAME batch — another row's insert
|
/// <c>transaction.Commit()</c> ran. A later failure in the SAME batch — another row's insert
|
||||||
/// throwing, or the commit itself failing (SQLITE_FULL, IOERR) — rolled the whole transaction
|
/// throwing, or the commit itself failing (SQLITE_FULL, IOERR) — rolled the whole transaction
|
||||||
@@ -102,6 +102,8 @@ public class SqliteAuditWriterCommitFailureTests
|
|||||||
return (writer, dataSource);
|
return (writer, dataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a mid-batch insert failure faults every row in the batch, not just the failing row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FlushBatch_MidBatchInsertFailure_FaultsEveryRowInBatch_NotJustTheFailingRow()
|
public async Task FlushBatch_MidBatchInsertFailure_FaultsEveryRowInBatch_NotJustTheFailingRow()
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,8 @@ public class SqliteAuditWriterCommitFailureTests
|
|||||||
Assert.Equal(0L, (long)countCmd.ExecuteScalar()!);
|
Assert.Equal(0L, (long)countCmd.ExecuteScalar()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the fallback writer rings every faulted row of a mid-batch insert failure and increments the failure counter once per row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FallbackAuditWriter_MidBatchInsertFailure_RingsEveryFaultedRow_AndIncrementsFailureCounterForEach()
|
public async Task FallbackAuditWriter_MidBatchInsertFailure_RingsEveryFaultedRow_AndIncrementsFailureCounterForEach()
|
||||||
{
|
{
|
||||||
|
|||||||
+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.
|
/// non-positive value has a documented fall-back-to-30s contract.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation
|
/// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation): it defaults to <c>""</c> (no CWD-relative default — mirrors
|
||||||
/// WP1.2): it defaults to <c>""</c> (no CWD-relative default — mirrors
|
|
||||||
/// <c>LocalDb:Path</c>), but this validator runs on both Central and Site
|
/// <c>LocalDb:Path</c>), but this validator runs on both Central and Site
|
||||||
/// composition roots via <c>ValidateOnStart</c>, and only Site nodes ever resolve
|
/// composition roots via <c>ValidateOnStart</c>, and only Site nodes ever resolve
|
||||||
/// the writer. The Site-only requirement is enforced pre-host by
|
/// the writer. The Site-only requirement is enforced pre-host by
|
||||||
@@ -20,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class SqliteAuditWriterOptionsValidatorTests
|
public class SqliteAuditWriterOptionsValidatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the default options pass validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultOptions_AreValid()
|
public void DefaultOptions_AreValid()
|
||||||
{
|
{
|
||||||
@@ -29,6 +29,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
|||||||
Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded);
|
Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a channel capacity of zero fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroChannelCapacity_Fails()
|
public void ZeroChannelCapacity_Fails()
|
||||||
{
|
{
|
||||||
@@ -39,6 +40,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal));
|
f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a batch size of zero fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroBatchSize_Fails()
|
public void ZeroBatchSize_Fails()
|
||||||
{
|
{
|
||||||
@@ -49,6 +51,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal));
|
f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a flush interval of zero fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroFlushInterval_Fails()
|
public void ZeroFlushInterval_Fails()
|
||||||
{
|
{
|
||||||
@@ -59,6 +62,8 @@ public class SqliteAuditWriterOptionsValidatorTests
|
|||||||
f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal));
|
f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a non-positive backlog poll interval still passes validation.</summary>
|
||||||
|
/// <param name="value">The non-positive poll interval, in milliseconds, to validate.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(0)]
|
[InlineData(0)]
|
||||||
[InlineData(-1)]
|
[InlineData(-1)]
|
||||||
|
|||||||
+13
@@ -19,6 +19,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|||||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db");
|
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db");
|
||||||
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } }
|
if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } }
|
||||||
@@ -33,6 +34,9 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|||||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
|
||||||
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
|
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
|
||||||
|
|
||||||
|
/// <summary>Verifies that the trace read plan uses the execution-id indexes instead of scanning the retained store.</summary>
|
||||||
|
/// <param name="idCount">The number of execution ids included in the read.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(1)]
|
[InlineData(1)]
|
||||||
[InlineData(64)]
|
[InlineData(64)]
|
||||||
@@ -56,6 +60,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|||||||
Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal));
|
Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that legacy rows with malformed details JSON do not break startup or trace reads.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads()
|
public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads()
|
||||||
{
|
{
|
||||||
@@ -81,6 +87,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|||||||
Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId);
|
Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the cursor pages through rows with equal timestamps without repeating or skipping any.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows()
|
public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows()
|
||||||
{
|
{
|
||||||
@@ -100,6 +108,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|||||||
first.Concat(second).Select(e => e.EventId));
|
first.Concat(second).Select(e => e.EventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that rows matching either the execution id or the parent execution id are returned in order and within the bound.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
|
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
|
||||||
{
|
{
|
||||||
@@ -145,6 +155,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|||||||
/// ApiOutbound row (or any other channel) stored under the execution id is served alongside
|
/// ApiOutbound row (or any other channel) stored under the execution id is served alongside
|
||||||
/// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in.
|
/// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ServesOutboundRows_NoChannelPredicate()
|
public async Task ServesOutboundRows_NoChannelPredicate()
|
||||||
{
|
{
|
||||||
@@ -171,6 +182,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|||||||
Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel);
|
Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the read guards reject an invalid limit and an oversized id list.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Guards_LimitAndIdCap()
|
public async Task Guards_LimitAndIdCap()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly string _dbPath;
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
/// <summary>Initializes the test class with a unique temporary database path.</summary>
|
||||||
public SqliteAuditWriterRetentionTests()
|
public SqliteAuditWriterRetentionTests()
|
||||||
{
|
{
|
||||||
_dbPath = Path.Combine(Path.GetTempPath(),
|
_dbPath = Path.Combine(Path.GetTempPath(),
|
||||||
$"audit-retention-{Guid.NewGuid():N}.db");
|
$"audit-retention-{Guid.NewGuid():N}.db");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (File.Exists(_dbPath))
|
if (File.Exists(_dbPath))
|
||||||
@@ -58,6 +60,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that purging deletes forwarded and reconciled rows older than the cutoff.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PurgeExpired_DeletesForwardedAndReconciled_OlderThanCutoff()
|
public async Task PurgeExpired_DeletesForwardedAndReconciled_OlderThanCutoff()
|
||||||
{
|
{
|
||||||
@@ -80,6 +84,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
|||||||
Assert.Equal(1, stats.PendingCount);
|
Assert.Equal(1, stats.PendingCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that purging expired rows is idempotent.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PurgeExpired_IsIdempotent()
|
public async Task PurgeExpired_IsIdempotent()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// C4 (Task 2.5) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>'s
|
/// Schema-bootstrap tests for <see cref="SqliteAuditWriter"/>'s
|
||||||
/// two-table site schema — the append-only canonical <c>audit_event</c> table +
|
/// two-table site schema — the append-only canonical <c>audit_event</c> table +
|
||||||
/// the mutable operational <c>audit_forward_state</c> sidecar + the <c>IX_fwd</c>
|
/// the mutable operational <c>audit_forward_state</c> sidecar + the <c>IX_fwd</c>
|
||||||
/// drain index. Uses an in-memory shared-cache SQLite database so the same
|
/// drain index. Uses an in-memory shared-cache SQLite database so the same
|
||||||
@@ -80,6 +80,7 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
|
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that opens, creates, audit, event, canonical, table, with, 10 columns, and, pk, on, event id.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId()
|
public void Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId()
|
||||||
{
|
{
|
||||||
@@ -113,6 +114,7 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that opens, creates, audit, forward, state, sidecar, table, with, expected, columns.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns()
|
public void Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns()
|
||||||
{
|
{
|
||||||
@@ -145,6 +147,7 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that opens, creates, ix, fwd, index, on, forward state, is cached kind, occurred.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred()
|
public void Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred()
|
||||||
{
|
{
|
||||||
@@ -180,6 +183,7 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that pragma, auto, vacuum, is, incremental.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PRAGMA_auto_vacuum_Is_INCREMENTAL()
|
public void PRAGMA_auto_vacuum_Is_INCREMENTAL()
|
||||||
{
|
{
|
||||||
@@ -265,6 +269,8 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that opening, over, pre existing, old single table, db, drops, it, and, creates, two, table, schema.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema()
|
public async Task Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema()
|
||||||
{
|
{
|
||||||
@@ -316,6 +322,8 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
|
|
||||||
// ----- Canonical / sidecar field persistence ----- //
|
// ----- Canonical / sidecar field persistence ----- //
|
||||||
|
|
||||||
|
/// <summary>Verifies that write async, persists, source node, field.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_persists_SourceNode_field()
|
public async Task WriteAsync_persists_SourceNode_field()
|
||||||
{
|
{
|
||||||
@@ -337,6 +345,8 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that write async, persists, null, source node.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_persists_null_SourceNode()
|
public async Task WriteAsync_persists_null_SourceNode()
|
||||||
{
|
{
|
||||||
@@ -358,6 +368,8 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that write async, execution id, round trips, through, details json.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson()
|
public async Task WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// C4 (Task 2.5) hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s
|
/// Hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s
|
||||||
/// two-table site schema. Exercise the Channel-based enqueue, the background
|
/// two-table site schema. Exercise the Channel-based enqueue, the background
|
||||||
/// writer's per-event canonical(<c>audit_event</c>) + sidecar
|
/// writer's per-event canonical(<c>audit_event</c>) + sidecar
|
||||||
/// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the
|
/// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the
|
||||||
@@ -87,7 +87,7 @@ public class SqliteAuditWriterWriteTests
|
|||||||
return byState;
|
return byState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// C4 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
// Build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||||||
// factory. The SQLite writer stores the 10 canonical fields directly in
|
// factory. The SQLite writer stores the 10 canonical fields directly in
|
||||||
// audit_event and writes a Pending sidecar row into audit_forward_state, with
|
// audit_event and writes a Pending sidecar row into audit_forward_state, with
|
||||||
// IsCachedKind precomputed from the event's Kind. Reads recompose the canonical
|
// IsCachedKind precomputed from the event's Kind. Reads recompose the canonical
|
||||||
@@ -120,6 +120,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
eventId: id ?? Guid.NewGuid(),
|
eventId: id ?? Guid.NewGuid(),
|
||||||
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow);
|
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>Verifies that a fresh event persists its canonical fields and a Pending sidecar row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending()
|
public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending()
|
||||||
{
|
{
|
||||||
@@ -140,6 +142,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
|
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the canonical fields written for an event round-trip unchanged through a read.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read()
|
public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read()
|
||||||
{
|
{
|
||||||
@@ -164,6 +168,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(evt.DetailsJson, row.DetailsJson);
|
Assert.Equal(evt.DetailsJson, row.DetailsJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that 1000 concurrent writes all persist without throwing.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions()
|
public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions()
|
||||||
{
|
{
|
||||||
@@ -186,16 +192,17 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
|
Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- FlushIntervalMs soft-flush coalescing (arch-review remediation WP1.2) ----- //
|
// ----- FlushIntervalMs soft-flush coalescing (arch-review remediation) ----- //
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A trickle of events — well under <see cref="SqliteAuditWriterOptions.BatchSize"/>,
|
/// A trickle of events — well under <see cref="SqliteAuditWriterOptions.BatchSize"/>,
|
||||||
/// spaced closer together than <see cref="SqliteAuditWriterOptions.FlushIntervalMs"/> —
|
/// spaced closer together than <see cref="SqliteAuditWriterOptions.FlushIntervalMs"/> —
|
||||||
/// must coalesce into ONE transaction rather than fsyncing once per event. This is the
|
/// must coalesce into ONE transaction rather than fsyncing once per event. This is the
|
||||||
/// bug WP1.2 fixes: <c>FlushIntervalMs</c> was validated but never read by the writer
|
/// bug this fixes: <c>FlushIntervalMs</c> was validated but never read by the writer
|
||||||
/// loop, so every trickle-rate write incurred its own commit (and, under SQLite's
|
/// loop, so every trickle-rate write incurred its own commit (and, under SQLite's
|
||||||
/// default synchronous=FULL, its own fsync).
|
/// default synchronous=FULL, its own fsync).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_TrickleLoad_CoalescesIntoOneTransaction()
|
public async Task WriteAsync_TrickleLoad_CoalescesIntoOneTransaction()
|
||||||
{
|
{
|
||||||
@@ -229,6 +236,7 @@ public class SqliteAuditWriterWriteTests
|
|||||||
/// waiting for a partner — the deadline bounds worst-case added latency, it does
|
/// waiting for a partner — the deadline bounds worst-case added latency, it does
|
||||||
/// not turn into an unbounded debounce.
|
/// not turn into an unbounded debounce.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_EventsSpacedBeyondFlushInterval_FlushSeparately()
|
public async Task WriteAsync_EventsSpacedBeyondFlushInterval_FlushSeparately()
|
||||||
{
|
{
|
||||||
@@ -249,6 +257,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(2, writer.FlushCountForTests);
|
Assert.Equal(2, writer.FlushCountForTests);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a duplicate event id is swallowed without an exception and the first write wins.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException()
|
public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException()
|
||||||
{
|
{
|
||||||
@@ -281,13 +291,15 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal("first", targetCmd.ExecuteScalar() as string);
|
Assert.Equal("first", targetCmd.ExecuteScalar() as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a write always sets the sidecar forward state to Pending.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_ForcesSidecarForwardStatePending()
|
public async Task WriteAsync_ForcesSidecarForwardStatePending()
|
||||||
{
|
{
|
||||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending));
|
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending));
|
||||||
await using var _ = writer;
|
await using var _ = writer;
|
||||||
|
|
||||||
// C4 (Task 2.5): ForwardState is not a field on the canonical record; a
|
// ForwardState is not a field on the canonical record; a
|
||||||
// fresh event's sidecar row defaults to Pending on INSERT.
|
// fresh event's sidecar row defaults to Pending on INSERT.
|
||||||
var evt = NewEvent();
|
var evt = NewEvent();
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
@@ -297,6 +309,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
|
|
||||||
// ----- IsCachedKind drain split (precomputed at insert) ----- //
|
// ----- IsCachedKind drain split (precomputed at insert) ----- //
|
||||||
|
|
||||||
|
/// <summary>Verifies that a cached audit kind sets the cached-kind flag to 1 and a non-cached kind sets it to 0.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0()
|
public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0()
|
||||||
{
|
{
|
||||||
@@ -320,6 +334,9 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
|
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that cached audit kinds drain through the cached-telemetry read and not the ordinary pending read.</summary>
|
||||||
|
/// <param name="kind">The cached audit kind under test.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(AuditKind.CachedSubmit)]
|
[InlineData(AuditKind.CachedSubmit)]
|
||||||
[InlineData(AuditKind.ApiCallCached)]
|
[InlineData(AuditKind.ApiCallCached)]
|
||||||
@@ -342,6 +359,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId);
|
Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a non-cached audit kind drains through the ordinary pending read and not the cached-telemetry read.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry()
|
public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry()
|
||||||
{
|
{
|
||||||
@@ -358,6 +377,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId);
|
Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the pending read returns the oldest rows first, limited to the requested count.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN()
|
public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN()
|
||||||
{
|
{
|
||||||
@@ -387,6 +408,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
|
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that marking events as forwarded flips their sidecar rows to Forwarded.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded()
|
public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded()
|
||||||
{
|
{
|
||||||
@@ -406,6 +429,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
|
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that marking events as forwarded increments the attempt count and stamps the last-attempt time.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc()
|
public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc()
|
||||||
{
|
{
|
||||||
@@ -428,6 +453,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped
|
Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that marking a non-existent event id as forwarded does not throw.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkForwardedAsync_NonExistentId_NoThrow()
|
public async Task MarkForwardedAsync_NonExistentId_NoThrow()
|
||||||
{
|
{
|
||||||
@@ -440,6 +467,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
// No assertion needed: the call must complete without throwing.
|
// No assertion needed: the call must complete without throwing.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the forwarded read returns only rows in the Forwarded state.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows()
|
public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows()
|
||||||
{
|
{
|
||||||
@@ -457,8 +486,10 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(forwarded.EventId, row.EventId);
|
Assert.Equal(forwarded.EventId, row.EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- M6 reconciliation pull surface ----- //
|
// ----- Reconciliation pull surface ----- //
|
||||||
|
|
||||||
|
/// <summary>Verifies that the since-read returns pending and forwarded rows oldest first, limited to the requested count.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN()
|
public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN()
|
||||||
{
|
{
|
||||||
@@ -488,6 +519,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
|
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the since-read excludes rows older than the supplied cutoff.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc()
|
public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc()
|
||||||
{
|
{
|
||||||
@@ -511,6 +544,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.DoesNotContain(rows, r => r.EventId == old.EventId);
|
Assert.DoesNotContain(rows, r => r.EventId == old.EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the since-read excludes rows that are already reconciled.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadPendingSinceAsync_ExcludesReconciledRows()
|
public async Task ReadPendingSinceAsync_ExcludesReconciledRows()
|
||||||
{
|
{
|
||||||
@@ -531,8 +566,10 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(pending.EventId, rows[0].EventId);
|
Assert.Equal(pending.EventId, rows[0].EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- WP2.3: composite keyset cursor + cursor-proved retirement ----- //
|
// ----- Composite keyset cursor + cursor-proved retirement ----- //
|
||||||
|
|
||||||
|
/// <summary>Verifies that supplying an after-id skips rows at or before the composite timestamp-and-id cursor.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadPendingSinceAsync_WithAfterId_SkipsRowsAtOrBeforeTheCompositeCursor()
|
public async Task ReadPendingSinceAsync_WithAfterId_SkipsRowsAtOrBeforeTheCompositeCursor()
|
||||||
{
|
{
|
||||||
@@ -567,6 +604,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(sameInstant.Select(e => e.EventId).ToHashSet(), allIds);
|
Assert.Equal(sameInstant.Select(e => e.EventId).ToHashSet(), allIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that reconciling up to a cursor with an id retires only rows at or before that cursor.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkReconciledUpToAsync_WithCursorId_RetiresOnlyRowsAtOrBeforeIt()
|
public async Task MarkReconciledUpToAsync_WithCursorId_RetiresOnlyRowsAtOrBeforeIt()
|
||||||
{
|
{
|
||||||
@@ -599,6 +638,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(3, remaining.Count); // the two un-consumed at the instant + newer
|
Assert.Equal(3, remaining.Count); // the two un-consumed at the instant + newer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that reconciling up to a timestamp without a cursor id leaves rows at the boundary instant servable.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkReconciledUpToAsync_WithoutCursorId_LeavesTheBoundaryInstantServable()
|
public async Task MarkReconciledUpToAsync_WithoutCursorId_LeavesTheBoundaryInstantServable()
|
||||||
{
|
{
|
||||||
@@ -626,6 +667,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(boundary.EventId, row.EventId);
|
Assert.Equal(boundary.EventId, row.EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that rows served without an advancing cursor remain servable so they are delivered at least once.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ServingRows_WithoutAnAdvancingCursor_KeepsThemServable_AtLeastOnce()
|
public async Task ServingRows_WithoutAnAdvancingCursor_KeepsThemServable_AtLeastOnce()
|
||||||
{
|
{
|
||||||
@@ -652,6 +695,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
second.Select(r => r.EventId).ToHashSet());
|
second.Select(r => r.EventId).ToHashSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a late-stamped insert below the cursor is never retired by reconciling up to the cursor.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkReconciledUpToAsync_LateStampedInsertBelowTheCursor_IsNeverRetired()
|
public async Task MarkReconciledUpToAsync_LateStampedInsertBelowTheCursor_IsNeverRetired()
|
||||||
{
|
{
|
||||||
@@ -695,6 +740,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(lateStamped.EventId, Assert.Single(reread).EventId);
|
Assert.Equal(lateStamped.EventId, Assert.Single(reread).EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that forwarded rows are retired by reconciling up to a cursor even if they were never pulled.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkReconciledUpToAsync_ForwardedRowsRetire_EvenWithoutHavingBeenPulled()
|
public async Task MarkReconciledUpToAsync_ForwardedRowsRetire_EvenWithoutHavingBeenPulled()
|
||||||
{
|
{
|
||||||
@@ -722,6 +769,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, neverShipped.EventId));
|
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, neverShipped.EventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that reconciling before any pull retires no pending rows.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkReconciledUpToAsync_BeforeAnyPull_RetiresNothingPending()
|
public async Task MarkReconciledUpToAsync_BeforeAnyPull_RetiresNothingPending()
|
||||||
{
|
{
|
||||||
@@ -745,6 +794,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(1, await writer.MarkReconciledUpToAsync(t0.AddHours(1), afterId: null));
|
Assert.Equal(1, await writer.MarkReconciledUpToAsync(t0.AddHours(1), afterId: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the since-read throws for an invalid batch size.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws()
|
public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws()
|
||||||
{
|
{
|
||||||
@@ -757,6 +808,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
() => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3));
|
() => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that marking events as reconciled flips both pending and forwarded rows to Reconciled.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled()
|
public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled()
|
||||||
{
|
{
|
||||||
@@ -781,6 +834,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
|
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that marking reconciled is idempotent and leaves already-reconciled rows untouched.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched()
|
public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched()
|
||||||
{
|
{
|
||||||
@@ -796,6 +851,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId));
|
Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that marking a non-existent event id as reconciled does not throw.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkReconciledAsync_NonExistentId_NoThrow()
|
public async Task MarkReconciledAsync_NonExistentId_NoThrow()
|
||||||
{
|
{
|
||||||
@@ -807,7 +864,7 @@ public class SqliteAuditWriterWriteTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fix 2 / M1 state guard: <see cref="SqliteAuditWriter.MarkForwardedAsync"/>
|
/// State guard: <see cref="SqliteAuditWriter.MarkForwardedAsync"/>
|
||||||
/// must NOT demote a <see cref="AuditForwardState.Reconciled"/> row back to
|
/// must NOT demote a <see cref="AuditForwardState.Reconciled"/> row back to
|
||||||
/// <see cref="AuditForwardState.Forwarded"/>. When a batch contains both a
|
/// <see cref="AuditForwardState.Forwarded"/>. When a batch contains both a
|
||||||
/// Pending ID and an already-Reconciled ID:
|
/// Pending ID and an already-Reconciled ID:
|
||||||
@@ -818,6 +875,7 @@ public class SqliteAuditWriterWriteTests
|
|||||||
/// This mirrors the idempotency guard already present on
|
/// This mirrors the idempotency guard already present on
|
||||||
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/>.
|
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions()
|
public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions()
|
||||||
{
|
{
|
||||||
@@ -879,6 +937,7 @@ public class SqliteAuditWriterWriteTests
|
|||||||
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> call flip atomically — and
|
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> call flip atomically — and
|
||||||
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> chunks the same way.
|
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> chunks the same way.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip()
|
public async Task MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip()
|
||||||
{
|
{
|
||||||
@@ -912,6 +971,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
|
|
||||||
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
|
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
|
||||||
|
|
||||||
|
/// <summary>Verifies that a non-null execution id round-trips through a write and read.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_NonNullExecutionId_RoundTrips()
|
public async Task WriteAsync_NonNullExecutionId_RoundTrips()
|
||||||
{
|
{
|
||||||
@@ -928,6 +989,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a null execution id round-trips as null.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
|
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
|
||||||
{
|
{
|
||||||
@@ -945,6 +1008,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
|
|
||||||
// ----- SourceNode stamping (Tasks 11/12) ----- //
|
// ----- SourceNode stamping (Tasks 11/12) ----- //
|
||||||
|
|
||||||
|
/// <summary>Verifies that the source node is stamped from the node identity provider when the event has none.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
|
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
|
||||||
{
|
{
|
||||||
@@ -962,6 +1027,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal("node-a", row.SourceNode);
|
Assert.Equal("node-a", row.SourceNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a source node supplied by the caller is preserved.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
|
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
|
||||||
{
|
{
|
||||||
@@ -980,6 +1047,8 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal("node-z", row.SourceNode);
|
Assert.Equal("node-z", row.SourceNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the source node stays null when the provider returns null.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
|
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
|
||||||
{
|
{
|
||||||
@@ -999,13 +1068,14 @@ public class SqliteAuditWriterWriteTests
|
|||||||
// ----- C4 hardening: safe enum-parse in MapRow ----- //
|
// ----- C4 hardening: safe enum-parse in MapRow ----- //
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// C4 hardening (Task 2.5): a row whose stored <c>Outcome</c> column holds an
|
/// C4 hardening: a row whose stored <c>Outcome</c> column holds an
|
||||||
/// unknown/renamed enum string must NOT fault the read path; it degrades
|
/// unknown/renamed enum string must NOT fault the read path; it degrades
|
||||||
/// gracefully to <see cref="AuditOutcome.Success"/> (the safe
|
/// gracefully to <see cref="AuditOutcome.Success"/> (the safe
|
||||||
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is
|
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is
|
||||||
/// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/>
|
/// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/>
|
||||||
/// surface which calls the private <c>MapRow</c>.
|
/// surface which calls the private <c>MapRow</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback()
|
public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback()
|
||||||
{
|
{
|
||||||
@@ -1033,8 +1103,10 @@ public class SqliteAuditWriterWriteTests
|
|||||||
Assert.Equal(AuditOutcome.Success, row.Outcome);
|
Assert.Equal(AuditOutcome.Success, row.Outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Task 24: pin the inclusive (>=) reconciliation cursor boundary ──
|
// ── Pin the inclusive (>=) reconciliation cursor boundary ──
|
||||||
|
|
||||||
|
/// <summary>Verifies that a row at the boundary timestamp is included in the since-read and that event-id deduplication absorbs the resulting duplicates.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates()
|
public async Task ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates()
|
||||||
{
|
{
|
||||||
|
|||||||
+39
-7
@@ -53,6 +53,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
SourceScript: sourceScript,
|
SourceScript: sourceScript,
|
||||||
ParentExecutionId: parentExecutionId);
|
ParentExecutionId: parentExecutionId);
|
||||||
|
|
||||||
|
/// <summary>Verifies that a transient failure emits one Attempted row and no resolve row.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
|
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
|
||||||
{
|
{
|
||||||
@@ -78,6 +80,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a delivered attempt emits an Attempted row and a CachedResolve row with Delivered status.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered()
|
public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered()
|
||||||
{
|
{
|
||||||
@@ -104,6 +108,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId);
|
Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a permanent failure emits an Attempted row and a CachedResolve row with Parked status.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked()
|
public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked()
|
||||||
{
|
{
|
||||||
@@ -123,6 +129,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal("Parked", captured[1].Operational.Status);
|
Assert.Equal("Parked", captured[1].Operational.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that parking after the maximum retries emits an Attempted row and a CachedResolve row with Parked status.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked()
|
public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked()
|
||||||
{
|
{
|
||||||
@@ -138,6 +146,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
|
Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the database channel maps to the DbWriteCached kind and the DbOutbound channel.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel()
|
public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel()
|
||||||
{
|
{
|
||||||
@@ -157,6 +167,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel);
|
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the bridge does not throw when the telemetry forwarder throws.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BridgeDoesNotThrow_WhenForwarderThrows()
|
public async Task BridgeDoesNotThrow_WhenForwarderThrows()
|
||||||
{
|
{
|
||||||
@@ -170,6 +182,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
|
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the bridge populates provenance fields from the attempt context.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BridgePopulatesProvenance_FromAttemptContext()
|
public async Task BridgePopulatesProvenance_FromAttemptContext()
|
||||||
{
|
{
|
||||||
@@ -192,12 +206,14 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId);
|
Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
|
// ── ExecutionId / SourceScript ──
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop Attempted row carries the ExecutionId and SourceScript from the attempt context.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext()
|
public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext()
|
||||||
{
|
{
|
||||||
// Task 4: the ExecutionId + SourceScript threaded through the S&F
|
// The ExecutionId + SourceScript threaded through the S&F
|
||||||
// buffer arrive on the CachedCallAttemptContext; the bridge must stamp
|
// buffer arrive on the CachedCallAttemptContext; the bridge must stamp
|
||||||
// both onto the per-attempt ApiCallCached row (previously SourceScript
|
// both onto the per-attempt ApiCallCached row (previously SourceScript
|
||||||
// was hard-coded null with a "not threaded through S&F" comment).
|
// was hard-coded null with a "not threaded through S&F" comment).
|
||||||
@@ -218,6 +234,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript);
|
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop CachedResolve row carries the ExecutionId and SourceScript from the attempt context.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
|
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
|
||||||
{
|
{
|
||||||
@@ -245,10 +263,12 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript);
|
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop row leaves ExecutionId and SourceScript null when the context has none.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull()
|
public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull()
|
||||||
{
|
{
|
||||||
// Back-compat: a pre-Task-4 buffered row has no ExecutionId /
|
// Back-compat: a buffered row written before ExecutionId was threaded through has no ExecutionId /
|
||||||
// SourceScript; the bridge must leave the audit row's fields null
|
// SourceScript; the bridge must leave the audit row's fields null
|
||||||
// rather than throwing.
|
// rather than throwing.
|
||||||
CachedCallTelemetry? captured = null;
|
CachedCallTelemetry? captured = null;
|
||||||
@@ -263,12 +283,14 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Null(captured.Audit.AsRow().SourceScript);
|
Assert.Null(captured.Audit.AsRow().SourceScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
|
// ── ParentExecutionId ──
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop Attempted row carries the ParentExecutionId from the attempt context.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext()
|
public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext()
|
||||||
{
|
{
|
||||||
// Task 6: the ParentExecutionId threaded through the S&F buffer (the
|
// The ParentExecutionId threaded through the S&F buffer (the
|
||||||
// inbound-API run that spawned the originating script) arrives on the
|
// inbound-API run that spawned the originating script) arrives on the
|
||||||
// CachedCallAttemptContext; the bridge must stamp it onto the
|
// CachedCallAttemptContext; the bridge must stamp it onto the
|
||||||
// per-attempt ApiCallCached row beside ExecutionId.
|
// per-attempt ApiCallCached row beside ExecutionId.
|
||||||
@@ -287,6 +309,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
|
Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop CachedResolve row carries the ParentExecutionId from the attempt context.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext()
|
public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext()
|
||||||
{
|
{
|
||||||
@@ -312,6 +336,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId);
|
Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop row leaves ParentExecutionId null when the context has none.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopRow_NullParentExecutionId_RemainsNull()
|
public async Task RetryLoopRow_NullParentExecutionId_RemainsNull()
|
||||||
{
|
{
|
||||||
@@ -329,12 +355,14 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Null(captured!.Audit.AsRow().ParentExecutionId);
|
Assert.Null(captured!.Audit.AsRow().ParentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SourceNode-stamping (Task 14) ──
|
// ── SourceNode stamping ──
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop row stamps the source node name from the node identity provider.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopRow_StampsSourceNode_FromNodeIdentityProvider()
|
public async Task RetryLoopRow_StampsSourceNode_FromNodeIdentityProvider()
|
||||||
{
|
{
|
||||||
// SourceNode-stamping (Task 14): when an INodeIdentityProvider is
|
// SourceNode stamping: when an INodeIdentityProvider is
|
||||||
// wired the bridge stamps the local node name (node-a/node-b) onto
|
// wired the bridge stamps the local node name (node-a/node-b) onto
|
||||||
// the SiteCallOperational.SourceNode column of every emitted packet.
|
// the SiteCallOperational.SourceNode column of every emitted packet.
|
||||||
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
|
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
|
||||||
@@ -353,6 +381,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.All(captured, p => Assert.Equal("node-a", p.Operational.SourceNode));
|
Assert.All(captured, p => Assert.Equal("node-a", p.Operational.SourceNode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop row leaves the source node null when no node identity provider is configured.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull()
|
public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull()
|
||||||
{
|
{
|
||||||
@@ -371,6 +401,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Null(packet.Operational.SourceNode);
|
Assert.Null(packet.Operational.SourceNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a retry-loop row leaves the source node null when the node identity has a null node name.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull()
|
public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull()
|
||||||
{
|
{
|
||||||
|
|||||||
+23
-3
@@ -111,6 +111,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
UpdatedAtUtc: _now,
|
UpdatedAtUtc: _now,
|
||||||
TerminalAtUtc: _now));
|
TerminalAtUtc: _now));
|
||||||
|
|
||||||
|
/// <summary>Verifies that forwarding a submit packet writes an audit event and records the enqueue.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue()
|
public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue()
|
||||||
{
|
{
|
||||||
@@ -130,7 +132,7 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
// Tracking row: insert-if-not-exists with kind discriminator.
|
// Tracking row: insert-if-not-exists with kind discriminator.
|
||||||
// Default CreateSut() does NOT supply an INodeIdentityProvider, so the
|
// Default CreateSut() does NOT supply an INodeIdentityProvider, so the
|
||||||
// forwarder passes null sourceNode to RecordEnqueueAsync (legacy / test
|
// forwarder passes null sourceNode to RecordEnqueueAsync (legacy / test
|
||||||
// host behaviour). The Task 14 stamping path is covered by the
|
// host behaviour). The stamping path is covered by the
|
||||||
// ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider test
|
// ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider test
|
||||||
// below.
|
// below.
|
||||||
await _tracking.Received(1).RecordEnqueueAsync(
|
await _tracking.Received(1).RecordEnqueueAsync(
|
||||||
@@ -147,6 +149,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
default, default!, default, default, default);
|
default, default!, default, default, default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that forwarding an attempted packet writes an audit event and records the attempt.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt()
|
public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt()
|
||||||
{
|
{
|
||||||
@@ -170,6 +174,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
default, default!, default, default, default);
|
default, default!, default, default, default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that forwarding a resolve packet writes an audit event and records the terminal state.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal()
|
public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal()
|
||||||
{
|
{
|
||||||
@@ -196,6 +202,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
// ── 2026-09-06: the audit row is enqueued, never awaited to commit ─────
|
// ── 2026-09-06: the audit row is enqueued, never awaited to commit ─────
|
||||||
// Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md
|
// Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md
|
||||||
|
|
||||||
|
/// <summary>Verifies that forwarding returns while the audit commit is still pending.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_ReturnsWhileTheAuditCommitIsStillPending()
|
public async Task ForwardAsync_ReturnsWhileTheAuditCommitIsStillPending()
|
||||||
{
|
{
|
||||||
@@ -216,6 +224,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
_id, "Delivered", Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
_id, "Delivered", Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a later audit commit fault is swallowed rather than surfaced to the caller.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_AuditCommitFaultsLater_IsSwallowed()
|
public async Task ForwardAsync_AuditCommitFaultsLater_IsSwallowed()
|
||||||
{
|
{
|
||||||
@@ -234,6 +244,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a throwing audit writer is logged and the exception does not propagate.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate()
|
public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate()
|
||||||
{
|
{
|
||||||
@@ -257,6 +269,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a throwing tracking store is logged and the exception does not propagate.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate()
|
public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate()
|
||||||
{
|
{
|
||||||
@@ -279,6 +293,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
|
Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that forwarding a null packet throws.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_NullPacket_Throws()
|
public async Task ForwardAsync_NullPacket_Throws()
|
||||||
{
|
{
|
||||||
@@ -288,12 +304,14 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
() => sut.ForwardAsync(null!, CancellationToken.None));
|
() => sut.ForwardAsync(null!, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SourceNode-stamping (Task 14) ──
|
// ── SourceNode stamping ──
|
||||||
|
|
||||||
|
/// <summary>Verifies that a submit packet is stamped with the source node from the node identity provider.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider()
|
public async Task ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider()
|
||||||
{
|
{
|
||||||
// SourceNode-stamping (Task 14): when an INodeIdentityProvider is
|
// SourceNode stamping: when an INodeIdentityProvider is
|
||||||
// wired the forwarder must stamp its NodeName onto the
|
// wired the forwarder must stamp its NodeName onto the
|
||||||
// RecordEnqueueAsync sourceNode parameter so the tracking row
|
// RecordEnqueueAsync sourceNode parameter so the tracking row
|
||||||
// captures the originating node (node-a/node-b).
|
// captures the originating node (node-a/node-b).
|
||||||
@@ -315,6 +333,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a node identity with a null node name results in a null source node.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode()
|
public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode()
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-4
@@ -4,15 +4,17 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle E E1 tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
|
/// Tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
|
||||||
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until M6
|
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until the
|
||||||
/// delivers the gRPC-backed implementation; both <c>IngestAuditEventsAsync</c>
|
/// gRPC-backed implementation is delivered; both <c>IngestAuditEventsAsync</c>
|
||||||
/// (M2) and <c>IngestCachedTelemetryAsync</c> (M3) must return an empty ack
|
/// and <c>IngestCachedTelemetryAsync</c> must return an empty ack
|
||||||
/// (no rows flipped to Forwarded) without throwing or partially handling the
|
/// (no rows flipped to Forwarded) without throwing or partially handling the
|
||||||
/// batch.
|
/// batch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NoOpSiteStreamAuditClientTests
|
public class NoOpSiteStreamAuditClientTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that ingesting an empty cached-telemetry batch returns an empty acknowledgement.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck()
|
public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck()
|
||||||
{
|
{
|
||||||
@@ -25,6 +27,8 @@ public class NoOpSiteStreamAuditClientTests
|
|||||||
Assert.Empty(ack.AcceptedEventIds);
|
Assert.Empty(ack.AcceptedEventIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that ingesting a populated batch still returns an empty acknowledgement, because the no-op client forwards nothing.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck()
|
public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck()
|
||||||
{
|
{
|
||||||
@@ -47,6 +51,8 @@ public class NoOpSiteStreamAuditClientTests
|
|||||||
Assert.Empty(ack.AcceptedEventIds);
|
Assert.Empty(ack.AcceptedEventIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that ingesting a null batch throws an <see cref="ArgumentNullException"/>.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestCachedTelemetryAsync_NullBatch_Throws()
|
public async Task IngestCachedTelemetryAsync_NullBatch_Throws()
|
||||||
{
|
{
|
||||||
|
|||||||
+31
-5
@@ -55,7 +55,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
(IOperationTrackingStore?)null)));
|
(IOperationTrackingStore?)null)));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AuditLog-001: builds an actor with the optional
|
/// Builds an actor with the optional
|
||||||
/// <see cref="IOperationTrackingStore"/> wired in so the cached-drain
|
/// <see cref="IOperationTrackingStore"/> wired in so the cached-drain
|
||||||
/// scheduler is armed alongside the audit-only drain. Used by the new
|
/// scheduler is armed alongside the audit-only drain. Used by the new
|
||||||
/// cached-drain regression tests below.
|
/// cached-drain regression tests below.
|
||||||
@@ -86,6 +86,9 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
return ack;
|
return ack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a drain of large rows stays within the wire budget and does not acknowledge rows that were not sent.</summary>
|
||||||
|
/// <param name="cached"><c>true</c> to drain cached-call rows, <c>false</c> to drain audit-only rows.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(false)]
|
[InlineData(false)]
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
@@ -126,6 +129,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Assert.Equal(256, sent.Count);
|
Assert.Equal(256, sent.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a drain of 50 pending rows sends one batch of 50 and then marks the rows as forwarded.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded()
|
public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded()
|
||||||
{
|
{
|
||||||
@@ -166,6 +171,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that rows stay pending when the gRPC call throws and the next drain retries them.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries()
|
public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries()
|
||||||
{
|
{
|
||||||
@@ -206,6 +213,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}");
|
Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a drain with no pending rows schedules the next drain at the idle interval without calling the client.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall()
|
public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall()
|
||||||
{
|
{
|
||||||
@@ -230,6 +239,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Assert.InRange(readCalls, 1, 2);
|
Assert.InRange(readCalls, 1, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a drain that finds pending rows schedules the next drain at the busy interval.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Drain_NonZeroPending_SchedulesAtBusyInterval()
|
public async Task Drain_NonZeroPending_SchedulesAtBusyInterval()
|
||||||
{
|
{
|
||||||
@@ -254,6 +265,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
$"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}");
|
$"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that only the event ids the central ack accepted are marked as forwarded.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted()
|
public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted()
|
||||||
{
|
{
|
||||||
@@ -292,7 +305,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
// AuditLog-001: combined-telemetry cached-drain regression tests. Verify
|
// Combined-telemetry cached-drain regression tests. Verify
|
||||||
// that the production wiring of the previously-unreachable cached transport
|
// that the production wiring of the previously-unreachable cached transport
|
||||||
// routes cached rows through ReadPendingCachedTelemetryAsync +
|
// routes cached rows through ReadPendingCachedTelemetryAsync +
|
||||||
// IngestCachedTelemetryAsync (and NOT IngestAuditEventsAsync), and that
|
// IngestCachedTelemetryAsync (and NOT IngestAuditEventsAsync), and that
|
||||||
@@ -305,7 +318,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Guid? eventId = null,
|
Guid? eventId = null,
|
||||||
Guid? correlationId = null,
|
Guid? correlationId = null,
|
||||||
string sourceSiteId = "site-1") =>
|
string sourceSiteId = "site-1") =>
|
||||||
// C3 (Task 2.5): canonical record via the shared factory; ForwardState is
|
// Canonical record via the shared factory; ForwardState is
|
||||||
// no longer a record field (the SQLite shim defaults it on INSERT).
|
// no longer a record field (the SQLite shim defaults it on INSERT).
|
||||||
ScadaBridgeAuditEventFactory.Create(
|
ScadaBridgeAuditEventFactory.Create(
|
||||||
channel: AuditChannel.ApiOutbound,
|
channel: AuditChannel.ApiOutbound,
|
||||||
@@ -335,12 +348,14 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
SourceScript: "script-1",
|
SourceScript: "script-1",
|
||||||
SourceNode: "node-a");
|
SourceNode: "node-a");
|
||||||
|
|
||||||
|
/// <summary>Verifies that cached rows are sent through the cached-telemetry RPC rather than the audit-events RPC.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CachedDrain_CachedRows_RouteThrough_IngestCachedTelemetry_NotIngestAuditEvents()
|
public async Task CachedDrain_CachedRows_RouteThrough_IngestCachedTelemetry_NotIngestAuditEvents()
|
||||||
{
|
{
|
||||||
// Arrange — three cached audit rows on the cached queue, each with a
|
// Arrange — three cached audit rows on the cached queue, each with a
|
||||||
// matching tracking snapshot. The audit-only queue is empty (those
|
// matching tracking snapshot. The audit-only queue is empty (those
|
||||||
// rows are excluded by ReadPendingAsync after AuditLog-001).
|
// rows are excluded by ReadPendingAsync after the combined-telemetry change).
|
||||||
var cachedRows = new[]
|
var cachedRows = new[]
|
||||||
{
|
{
|
||||||
NewCachedEvent(AuditKind.CachedSubmit),
|
NewCachedEvent(AuditKind.CachedSubmit),
|
||||||
@@ -401,6 +416,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Assert.Equal(expectedIds, emittedEventIds);
|
Assert.Equal(expectedIds, emittedEventIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a cached row with no tracking snapshot past the grace period is abandoned while a valid row still flows.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CachedDrain_OrphanRow_PastGrace_IsAbandoned_AndTheValidRowStillFlows()
|
public async Task CachedDrain_OrphanRow_PastGrace_IsAbandoned_AndTheValidRowStillFlows()
|
||||||
{
|
{
|
||||||
@@ -494,6 +511,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Assert.Equal(valid.EventId.ToString(), capturedBatch.Packets[0].AuditEvent.EventId);
|
Assert.Equal(valid.EventId.ToString(), capturedBatch.Packets[0].AuditEvent.EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a cached row with no tracking snapshot inside the grace period is retried rather than abandoned.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CachedDrain_OrphanRow_InsideGrace_IsRetried_NotAbandoned()
|
public async Task CachedDrain_OrphanRow_InsideGrace_IsRetried_NotAbandoned()
|
||||||
{
|
{
|
||||||
@@ -536,6 +555,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a full batch of unresolvable cached rows does not starve the rest of the queue.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue()
|
public async Task CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue()
|
||||||
{
|
{
|
||||||
@@ -575,6 +596,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>());
|
Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tracking-store failure does not cause the cached row to be abandoned.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CachedDrain_TrackingStoreThrow_DoesNotAbandonTheRow()
|
public async Task CachedDrain_TrackingStoreThrow_DoesNotAbandonTheRow()
|
||||||
{
|
{
|
||||||
@@ -608,13 +631,15 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that audit-only rows still drain when the cached drain is disabled.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AuditOnlyDrain_StillFlows_When_CachedDrain_IsDisabled()
|
public async Task AuditOnlyDrain_StillFlows_When_CachedDrain_IsDisabled()
|
||||||
{
|
{
|
||||||
// Arrange — ordinary (non-cached) audit rows on the audit-only queue;
|
// Arrange — ordinary (non-cached) audit rows on the audit-only queue;
|
||||||
// the actor is constructed WITHOUT a tracking store so the cached
|
// the actor is constructed WITHOUT a tracking store so the cached
|
||||||
// scheduler is never armed. Regression guard against the audit-only
|
// scheduler is never armed. Regression guard against the audit-only
|
||||||
// drain regressing during the AuditLog-001 refactor.
|
// drain regressing during the combined-telemetry refactor.
|
||||||
var rows = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
|
var rows = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
|
||||||
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(
|
.Returns(
|
||||||
@@ -655,6 +680,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
/// test in this class masks the bug by returning already-completed tasks
|
/// test in this class masks the bug by returning already-completed tasks
|
||||||
/// from the mocks, which keeps the continuations on the actor thread.
|
/// from the mocks, which keeps the continuations on the actor thread.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Drains_Whose_Awaits_Complete_Off_The_Actor_Thread_Keep_Draining_Without_Crashing()
|
public async Task Drains_Whose_Awaits_Complete_Off_The_Actor_Thread_Keep_Draining_Without_Crashing()
|
||||||
{
|
{
|
||||||
|
|||||||
+13
-1
@@ -61,6 +61,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
|||||||
UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)),
|
UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Verifies that a full acknowledgement maps all accepted event ids onto the returned ack.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck()
|
public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck()
|
||||||
{
|
{
|
||||||
@@ -88,6 +90,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
|||||||
ack.AcceptedEventIds.ToHashSet());
|
ack.AcceptedEventIds.ToHashSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a partial acknowledgement lists only the accepted event ids on the returned ack.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck()
|
public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck()
|
||||||
{
|
{
|
||||||
@@ -110,6 +114,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
|||||||
ack.AcceptedEventIds.ToHashSet());
|
ack.AcceptedEventIds.ToHashSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an Ask timeout throws so the drain loop keeps its rows pending.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending()
|
public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending()
|
||||||
{
|
{
|
||||||
@@ -127,6 +133,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
|||||||
await Assert.ThrowsAnyAsync<Exception>(() => task);
|
await Assert.ThrowsAnyAsync<Exception>(() => task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a faulted reply causes the ingest call to throw.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestAuditEventsAsync_FaultedReply_Throws()
|
public async Task IngestAuditEventsAsync_FaultedReply_Throws()
|
||||||
{
|
{
|
||||||
@@ -136,13 +144,15 @@ public class SiteCommunicationAuditClientTests : TestKit
|
|||||||
var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None);
|
var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None);
|
||||||
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
|
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
// A Status.Failure from central (Task 1: central does not swallow an
|
// A Status.Failure from central (central does not swallow an
|
||||||
// ingest fault into an empty ack) must propagate as a thrown exception.
|
// ingest fault into an empty ack) must propagate as a thrown exception.
|
||||||
probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted")));
|
probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted")));
|
||||||
|
|
||||||
await Assert.ThrowsAnyAsync<Exception>(() => task);
|
await Assert.ThrowsAnyAsync<Exception>(() => task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that cached telemetry is routed as a command and the reply is mapped back.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply()
|
public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply()
|
||||||
{
|
{
|
||||||
@@ -178,6 +188,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
|||||||
ack.AcceptedEventIds.ToHashSet());
|
ack.AcceptedEventIds.ToHashSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an Ask timeout during cached telemetry ingestion throws.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IngestCachedTelemetryAsync_AskTimeout_Throws()
|
public async Task IngestCachedTelemetryAsync_AskTimeout_Throws()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider
|
internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider
|
||||||
{
|
{
|
||||||
|
// Returns the configured node name, which may be null.
|
||||||
|
/// <inheritdoc />
|
||||||
public string? NodeName { get; }
|
public string? NodeName { get; }
|
||||||
|
|
||||||
|
/// <summary>Initializes the fake with the node name to report.</summary>
|
||||||
|
/// <param name="nodeName">The node name to report, or null for none.</param>
|
||||||
public FakeNodeIdentityProvider(string? nodeName = null)
|
public FakeNodeIdentityProvider(string? nodeName = null)
|
||||||
{
|
{
|
||||||
NodeName = nodeName;
|
NodeName = nodeName;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ internal static class StubAuditRepositoryProvider
|
|||||||
/// Builds a provider whose every scope yields the same audit-log repository instance.
|
/// Builds a provider whose every scope yields the same audit-log repository instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="repository">Repository instance shared by every scope.</param>
|
/// <param name="repository">Repository instance shared by every scope.</param>
|
||||||
|
/// <returns>A service provider whose scopes all resolve the supplied repository.</returns>
|
||||||
public static ServiceProvider Build(IAuditLogRepository repository)
|
public static ServiceProvider Build(IAuditLogRepository repository)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(repository);
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
totalCount),
|
totalCount),
|
||||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
|
|
||||||
|
/// <summary>Verifies that projecting entries unwraps the envelope into rows.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
|
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
|
||||||
{
|
{
|
||||||
@@ -68,6 +69,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
Assert.Equal("MES", second.GetProperty("entityName").GetString());
|
Assert.Equal("MES", second.GetProperty("entityName").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that projecting entries drops the after-state JSON.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_DropsAfterStateJson()
|
public void ProjectEntries_DropsAfterStateJson()
|
||||||
{
|
{
|
||||||
@@ -80,6 +82,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
|
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a truncated page appends a note with the total count.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
|
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
|
||||||
{
|
{
|
||||||
@@ -96,6 +99,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
Assert.False(root[2].TryGetProperty("id", out _));
|
Assert.False(root[2].TryGetProperty("id", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a result set returned in full emits no note column.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
|
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
|
||||||
{
|
{
|
||||||
@@ -108,6 +112,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
|
Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an empty result stays an empty array.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
|
public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
|
||||||
{
|
{
|
||||||
@@ -124,6 +129,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a bare array is still projected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_BareArray_IsStillProjected()
|
public void ProjectEntries_BareArray_IsStillProjected()
|
||||||
{
|
{
|
||||||
@@ -143,6 +149,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
|
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unrecognised JSON object is returned verbatim.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
|
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
|
||||||
{
|
{
|
||||||
@@ -152,6 +159,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
|
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that non-JSON input is returned verbatim.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_NonJson_ReturnedVerbatim()
|
public void ProjectEntries_NonJson_ReturnedVerbatim()
|
||||||
{
|
{
|
||||||
@@ -159,6 +167,7 @@ public class AuditConfigTableProjectionTests
|
|||||||
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
|
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the projection is substantially smaller than the raw payload.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
|
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
|||||||
[Collection("Environment")]
|
[Collection("Environment")]
|
||||||
public class CliConfigTests
|
public class CliConfigTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that loading configuration with no overrides defaults the output format to JSON.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_DefaultFormat_IsJson()
|
public void Load_DefaultFormat_IsJson()
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,7 @@ public class CliConfigTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the management URL is read from the environment.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_ManagementUrl_FromEnvironment()
|
public void Load_ManagementUrl_FromEnvironment()
|
||||||
{
|
{
|
||||||
@@ -46,6 +48,7 @@ public class CliConfigTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the output format is read from the environment.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_Format_FromEnvironment()
|
public void Load_Format_FromEnvironment()
|
||||||
{
|
{
|
||||||
@@ -65,7 +68,7 @@ public class CliConfigTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CLI-021 regression: a malformed ~/.scadabridge/config.json must NOT abort the
|
/// Regression: a malformed ~/.scadabridge/config.json must NOT abort the
|
||||||
/// CLI before any command runs — Load() must warn (to stderr) and return a
|
/// CLI before any command runs — Load() must warn (to stderr) and return a
|
||||||
/// usable default config so command-line overrides (--url, --username, etc.)
|
/// usable default config so command-line overrides (--url, --username, etc.)
|
||||||
/// and env vars can still take effect.
|
/// and env vars can still take effect.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
|||||||
[Collection("Console")]
|
[Collection("Console")]
|
||||||
public class CommandHelpersTests
|
public class CommandHelpersTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that a successful response in JSON format returns exit code zero.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_Success_JsonFormat_ReturnsZero()
|
public void HandleResponse_Success_JsonFormat_ReturnsZero()
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,7 @@ public class CommandHelpersTests
|
|||||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a successful response with array data in table format returns exit code zero.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_Success_TableFormat_ArrayData_ReturnsZero()
|
public void HandleResponse_Success_TableFormat_ArrayData_ReturnsZero()
|
||||||
{
|
{
|
||||||
@@ -41,6 +43,7 @@ public class CommandHelpersTests
|
|||||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a successful response with object data in table format returns exit code zero.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_Success_TableFormat_ObjectData_ReturnsZero()
|
public void HandleResponse_Success_TableFormat_ObjectData_ReturnsZero()
|
||||||
{
|
{
|
||||||
@@ -61,6 +64,7 @@ public class CommandHelpersTests
|
|||||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an empty array in table format reports that there are no results.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_Success_TableFormat_EmptyArray_ShowsNoResults()
|
public void HandleResponse_Success_TableFormat_EmptyArray_ShowsNoResults()
|
||||||
{
|
{
|
||||||
@@ -76,6 +80,7 @@ public class CommandHelpersTests
|
|||||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an error response returns exit code one.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_Error_ReturnsOne()
|
public void HandleResponse_Error_ReturnsOne()
|
||||||
{
|
{
|
||||||
@@ -91,6 +96,7 @@ public class CommandHelpersTests
|
|||||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unauthorized response returns exit code two.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_Unauthorized_ReturnsTwo()
|
public void HandleResponse_Unauthorized_ReturnsTwo()
|
||||||
{
|
{
|
||||||
@@ -106,6 +112,7 @@ public class CommandHelpersTests
|
|||||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an authentication failure response returns exit code one.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_AuthFailure_ReturnsOne()
|
public void HandleResponse_AuthFailure_ReturnsOne()
|
||||||
{
|
{
|
||||||
@@ -121,6 +128,7 @@ public class CommandHelpersTests
|
|||||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a connection failure response returns exit code one.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_ConnectionFailure_ReturnsOne()
|
public void HandleResponse_ConnectionFailure_ReturnsOne()
|
||||||
{
|
{
|
||||||
@@ -136,6 +144,7 @@ public class CommandHelpersTests
|
|||||||
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a timeout response returns exit code one.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_Timeout_ReturnsOne()
|
public void HandleResponse_Timeout_ReturnsOne()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Regression tests for CLI-013 — the command-tree wiring was untested. These tests
|
/// Regression tests for the command-tree wiring, which was previously untested. These tests
|
||||||
/// build every command group and assert the tree is well-formed (every leaf has an
|
/// build every command group and assert the tree is well-formed (every leaf has an
|
||||||
/// action, no group is empty), and that every management command record the CLI sends
|
/// action, no group is empty), and that every management command record the CLI sends
|
||||||
/// resolves via <see cref="ManagementCommandRegistry"/> (so command-name derivation
|
/// resolves via <see cref="ManagementCommandRegistry"/> (so command-name derivation
|
||||||
@@ -21,6 +21,7 @@ public class CommandTreeTests
|
|||||||
// Exercise the production root: a separately maintained group list can drift.
|
// Exercise the production root: a separately maintained group list can drift.
|
||||||
private static IEnumerable<Command> AllCommandGroups() => CliApplication.BuildRoot().Subcommands;
|
private static IEnumerable<Command> AllCommandGroups() => CliApplication.BuildRoot().Subcommands;
|
||||||
|
|
||||||
|
/// <summary>Verifies that the registered operational command groups include the secured-writes and connection tools.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
|
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
|
||||||
{
|
{
|
||||||
@@ -47,6 +48,7 @@ public class CommandTreeTests
|
|||||||
yield return leaf;
|
yield return leaf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that every command group builds without throwing.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AllCommandGroups_Build_WithoutThrowing()
|
public void AllCommandGroups_Build_WithoutThrowing()
|
||||||
{
|
{
|
||||||
@@ -55,10 +57,11 @@ public class CommandTreeTests
|
|||||||
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the set of command groups contains the audit and bundle groups.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AllCommandGroups_Contains_AuditAndBundle()
|
public void AllCommandGroups_Contains_AuditAndBundle()
|
||||||
{
|
{
|
||||||
// CLI-022: explicit group-presence assertion so the harness does not
|
// Explicit group-presence assertion so the harness does not
|
||||||
// silently drift back to excluding new groups. Use names because that
|
// silently drift back to excluding new groups. Use names because that
|
||||||
// is what users actually type at the prompt.
|
// is what users actually type at the prompt.
|
||||||
var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet();
|
var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet();
|
||||||
@@ -66,10 +69,11 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("bundle", groupNames);
|
Assert.Contains("bundle", groupNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the audit command group exposes the query, export and verify-chain subcommands.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditCommandGroup_HasQueryExportAndVerifyChain()
|
public void AuditCommandGroup_HasQueryExportAndVerifyChain()
|
||||||
{
|
{
|
||||||
// CLI-022: pin the audit sub-command surface so a rename / accidental
|
// Pin the audit sub-command surface so a rename / accidental
|
||||||
// removal of one of these is caught.
|
// removal of one of these is caught.
|
||||||
var audit = AuditCommands.Build(Url, Format, Username, Password);
|
var audit = AuditCommands.Build(Url, Format, Username, Password);
|
||||||
var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet();
|
var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet();
|
||||||
@@ -78,10 +82,11 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("verify-chain", subNames);
|
Assert.Contains("verify-chain", subNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the bundle command group exposes the export, preview and import subcommands.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BundleCommandGroup_HasExportPreviewAndImport()
|
public void BundleCommandGroup_HasExportPreviewAndImport()
|
||||||
{
|
{
|
||||||
// CLI-022: pin the bundle sub-command surface.
|
// Pin the bundle sub-command surface.
|
||||||
var bundle = BundleCommands.Build(Url, Format, Username, Password);
|
var bundle = BundleCommands.Build(Url, Format, Username, Password);
|
||||||
var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet();
|
var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet();
|
||||||
Assert.Contains("export", subNames);
|
Assert.Contains("export", subNames);
|
||||||
@@ -89,6 +94,7 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("import", subNames);
|
Assert.Contains("import", subNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that every leaf command in the tree has an action attached.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EveryLeafCommand_HasAnAction()
|
public void EveryLeafCommand_HasAnAction()
|
||||||
{
|
{
|
||||||
@@ -100,10 +106,11 @@ public class CommandTreeTests
|
|||||||
Assert.True(leaf.Action != null, $"Leaf command '{leaf.Name}' has no action."));
|
Assert.True(leaf.Action != null, $"Leaf command '{leaf.Name}' has no action."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that template composition delete is keyed by the composition id alone.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateCompositionDelete_IsKeyedByIdOnly()
|
public void TemplateCompositionDelete_IsKeyedByIdOnly()
|
||||||
{
|
{
|
||||||
// CLI-015: the in-repo README documented `template composition delete` with
|
// The in-repo README documented `template composition delete` with
|
||||||
// --template-id / --instance-name, but the implementation keys deletion by the
|
// --template-id / --instance-name, but the implementation keys deletion by the
|
||||||
// composition's own integer ID via a single --id option. Pin the real surface.
|
// composition's own integer ID via a single --id option. Pin the real surface.
|
||||||
var template = TemplateCommands.Build(Url, Format, Username, Password);
|
var template = TemplateCommands.Build(Url, Format, Username, Password);
|
||||||
@@ -116,6 +123,7 @@ public class CommandTreeTests
|
|||||||
Assert.DoesNotContain("--instance-name", optionNames);
|
Assert.DoesNotContain("--instance-name", optionNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the template native alarm source group has add, list and remove subcommands.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateNativeAlarmSource_HasAddListRemove()
|
public void TemplateNativeAlarmSource_HasAddListRemove()
|
||||||
{
|
{
|
||||||
@@ -127,10 +135,11 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("remove", subNames);
|
Assert.Contains("remove", subNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the deploy group has a site verb that requires a site id.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Deploy_HasSiteVerb_WithRequiredSiteId()
|
public void Deploy_HasSiteVerb_WithRequiredSiteId()
|
||||||
{
|
{
|
||||||
// WP2.5: bulk site deploy. Named `deploy site` to sit alongside
|
// Bulk site deploy. Named `deploy site` to sit alongside
|
||||||
// `deploy instance` / `deploy artifacts` — the group's convention is that
|
// `deploy instance` / `deploy artifacts` — the group's convention is that
|
||||||
// the verb names the SCOPE of the deploy.
|
// the verb names the SCOPE of the deploy.
|
||||||
var deploy = DeployCommands.Build(Url, Format, Username, Password);
|
var deploy = DeployCommands.Build(Url, Format, Username, Password);
|
||||||
@@ -143,10 +152,11 @@ public class CommandTreeTests
|
|||||||
Assert.Empty(site.Parse(["--site-id", "3"]).Errors);
|
Assert.Empty(site.Parse(["--site-id", "3"]).Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that deploy site has a force flag that defaults to the non-destructive behaviour.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DeploySite_HasForceFlag_DefaultingToTheNonDestructiveBehaviour()
|
public void DeploySite_HasForceFlag_DefaultingToTheNonDestructiveBehaviour()
|
||||||
{
|
{
|
||||||
// P7-6: bulk deploy skips instances whose config is unchanged, because a
|
// Bulk deploy skips instances whose config is unchanged, because a
|
||||||
// redeploy resets their static overrides and native alarm state at the
|
// redeploy resets their static overrides and native alarm state at the
|
||||||
// site. --force is the operator escape hatch (restored volume, rebuilt
|
// site. --force is the operator escape hatch (restored volume, rebuilt
|
||||||
// node) and must be a FLAG — absent means skip-unchanged, which is the
|
// node) and must be a FLAG — absent means skip-unchanged, which is the
|
||||||
@@ -165,6 +175,7 @@ public class CommandTreeTests
|
|||||||
Assert.True(withForce.GetValue(forceOption));
|
Assert.True(withForce.GetValue(forceOption));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the instance native alarm source group has set and clear subcommands.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InstanceNativeAlarmSource_HasSetAndClear()
|
public void InstanceNativeAlarmSource_HasSetAndClear()
|
||||||
{
|
{
|
||||||
@@ -175,6 +186,7 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("clear", subNames);
|
Assert.Contains("clear", subNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the template folder group covers every folder management command.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateFolder_HasFullManagementCommandParity()
|
public void TemplateFolder_HasFullManagementCommandParity()
|
||||||
{
|
{
|
||||||
@@ -192,6 +204,7 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("delete", subNames);
|
Assert.Contains("delete", subNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the parent id is optional on template folder create so the root folder is reachable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable()
|
public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable()
|
||||||
{
|
{
|
||||||
@@ -211,6 +224,7 @@ public class CommandTreeTests
|
|||||||
Assert.Equal(7, nestedParse.GetValue((Option<int?>)parentOption));
|
Assert.Equal(7, nestedParse.GetValue((Option<int?>)parentOption));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the parent id is optional on template folder move so the root folder is reachable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable()
|
public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable()
|
||||||
{
|
{
|
||||||
@@ -224,6 +238,8 @@ public class CommandTreeTests
|
|||||||
Assert.Null(parse.GetValue((Option<int?>)parentOption));
|
Assert.Null(parse.GetValue((Option<int?>)parentOption));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that template folder reorder accepts the up and down directions.</summary>
|
||||||
|
/// <param name="direction">The reorder direction under test.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("up")]
|
[InlineData("up")]
|
||||||
[InlineData("down")]
|
[InlineData("down")]
|
||||||
@@ -237,6 +253,8 @@ public class CommandTreeTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the folder reorder command rejects any direction other than the accepted literals.</summary>
|
||||||
|
/// <param name="direction">The rejected direction value.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("sideways")]
|
[InlineData("sideways")]
|
||||||
// AcceptOnlyFromAmong is case-SENSITIVE, so the literals are lowercase only.
|
// AcceptOnlyFromAmong is case-SENSITIVE, so the literals are lowercase only.
|
||||||
@@ -245,6 +263,8 @@ public class CommandTreeTests
|
|||||||
// case-sensitivity rejected values the server accepts. --direction keeps it: it is
|
// case-sensitivity rejected values the server accepts. --direction keeps it: it is
|
||||||
// a pure client-side literal that never reaches a server-side parse, so there is no
|
// a pure client-side literal that never reaches a server-side parse, so there is no
|
||||||
// authoritative check behind it to defer to.
|
// authoritative check behind it to defer to.
|
||||||
|
/// <summary>Verifies that template folder reorder rejects any direction other than up or down.</summary>
|
||||||
|
/// <param name="direction">The invalid reorder direction under test.</param>
|
||||||
[InlineData("Up")]
|
[InlineData("Up")]
|
||||||
public void TemplateFolderReorder_RejectsAnythingElse(string direction)
|
public void TemplateFolderReorder_RejectsAnythingElse(string direction)
|
||||||
{
|
{
|
||||||
@@ -256,6 +276,7 @@ public class CommandTreeTests
|
|||||||
Assert.NotEmpty(parse.Errors);
|
Assert.NotEmpty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that template folder reorder requires a direction.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateFolderReorder_RequiresDirection()
|
public void TemplateFolderReorder_RequiresDirection()
|
||||||
{
|
{
|
||||||
@@ -278,6 +299,8 @@ public class CommandTreeTests
|
|||||||
// treatment row 88 gave `deploy status`). These tests fail before that change.
|
// treatment row 88 gave `deploy status`). These tests fail before that change.
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
|
/// <summary>Provides the audit filter option names and sample values used by the theory data.</summary>
|
||||||
|
/// <returns>Theory data of subcommand, option and value triples.</returns>
|
||||||
public static TheoryData<string, string, string> AuditFilterValues() => new()
|
public static TheoryData<string, string, string> AuditFilterValues() => new()
|
||||||
{
|
{
|
||||||
// subcommand, option, value — lowercase, uppercase and mixed case of a real member.
|
// subcommand, option, value — lowercase, uppercase and mixed case of a real member.
|
||||||
@@ -290,6 +313,10 @@ public class CommandTreeTests
|
|||||||
{ "export", "--kind", "dbwrite" },
|
{ "export", "--kind", "dbwrite" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Verifies that audit filter options accept values in any letter case.</summary>
|
||||||
|
/// <param name="subcommand">The audit subcommand under test.</param>
|
||||||
|
/// <param name="option">The filter option name.</param>
|
||||||
|
/// <param name="value">The option value whose casing varies.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[MemberData(nameof(AuditFilterValues))]
|
[MemberData(nameof(AuditFilterValues))]
|
||||||
public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value)
|
public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value)
|
||||||
@@ -308,6 +335,8 @@ public class CommandTreeTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the audit export format option accepts values in any letter case.</summary>
|
||||||
|
/// <param name="format">The export format value under test.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("csv")]
|
[InlineData("csv")]
|
||||||
[InlineData("CSV")]
|
[InlineData("CSV")]
|
||||||
@@ -328,6 +357,7 @@ public class CommandTreeTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unrecognised audit query filter value is not rejected on the client side.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide()
|
public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide()
|
||||||
{
|
{
|
||||||
@@ -342,6 +372,7 @@ public class CommandTreeTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the site area group has a move verb.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SiteArea_HasMoveVerb()
|
public void SiteArea_HasMoveVerb()
|
||||||
{
|
{
|
||||||
@@ -353,6 +384,7 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("move", subNames);
|
Assert.Contains("move", subNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the parent id is optional on site area move so the site root is reachable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable()
|
public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable()
|
||||||
{
|
{
|
||||||
@@ -372,6 +404,7 @@ public class CommandTreeTests
|
|||||||
Assert.Equal(2, reparentParse.GetValue((Option<int?>)parentOption));
|
Assert.Equal(2, reparentParse.GetValue((Option<int?>)parentOption));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that site area move requires an area id.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SiteAreaMove_RequiresId()
|
public void SiteAreaMove_RequiresId()
|
||||||
{
|
{
|
||||||
@@ -383,6 +416,7 @@ public class CommandTreeTests
|
|||||||
Assert.NotEmpty(parse.Errors);
|
Assert.NotEmpty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that template list has skip and take paging options.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateList_HasSkipAndTakePagingOptions()
|
public void TemplateList_HasSkipAndTakePagingOptions()
|
||||||
{
|
{
|
||||||
@@ -394,6 +428,7 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("--take", optionNames);
|
Assert.Contains("--take", optionNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that instance list has skip and take paging options.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InstanceList_HasSkipAndTakePagingOptions()
|
public void InstanceList_HasSkipAndTakePagingOptions()
|
||||||
{
|
{
|
||||||
@@ -405,6 +440,7 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("--take", optionNames);
|
Assert.Contains("--take", optionNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that instance list parses the value of the take option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InstanceList_ParsesTakeOptionValue()
|
public void InstanceList_ParsesTakeOptionValue()
|
||||||
{
|
{
|
||||||
@@ -418,6 +454,8 @@ public class CommandTreeTests
|
|||||||
Assert.Equal(5, parse.GetValue((Option<int?>)takeOption));
|
Assert.Equal(5, parse.GetValue((Option<int?>)takeOption));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that each command payload type resolves through the management command registry.</summary>
|
||||||
|
/// <param name="commandType">The command payload type under test.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(typeof(GetInstanceCommand))]
|
[InlineData(typeof(GetInstanceCommand))]
|
||||||
[InlineData(typeof(ListSitesCommand))]
|
[InlineData(typeof(ListSitesCommand))]
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand
|
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand:
|
||||||
/// (Audit Log #23 M5.6 T5): argument parsing, request-body construction,
|
/// argument parsing, request-body construction, HTTP wiring, and CLI scaffold.
|
||||||
/// HTTP wiring, and CLI scaffold.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("Console")]
|
[Collection("Console")]
|
||||||
public class AuditBackfillCommandTests
|
public class AuditBackfillCommandTests
|
||||||
@@ -19,6 +18,7 @@ public class AuditBackfillCommandTests
|
|||||||
// BuildRequestBody
|
// BuildRequestBody
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that building a request body from default arguments contains the expected sentinel, before and batch size fields.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildRequestBody_DefaultArgs_ContainsExpectedFields()
|
public void BuildRequestBody_DefaultArgs_ContainsExpectedFields()
|
||||||
{
|
{
|
||||||
@@ -38,6 +38,7 @@ public class AuditBackfillCommandTests
|
|||||||
Assert.Equal(5000, root.GetProperty("batchSize").GetInt32());
|
Assert.Equal(5000, root.GetProperty("batchSize").GetInt32());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a custom sentinel and batch size are reflected in the request body JSON.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson()
|
public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson()
|
||||||
{
|
{
|
||||||
@@ -66,16 +67,23 @@ public class AuditBackfillCommandTests
|
|||||||
private readonly HttpStatusCode _status;
|
private readonly HttpStatusCode _status;
|
||||||
private readonly string _responseBody;
|
private readonly string _responseBody;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new handler that returns the given status and body for every request.</summary>
|
||||||
|
/// <param name="status">The HTTP status code to return.</param>
|
||||||
|
/// <param name="responseBody">The response body to return.</param>
|
||||||
public CapturingHandler(HttpStatusCode status, string responseBody)
|
public CapturingHandler(HttpStatusCode status, string responseBody)
|
||||||
{
|
{
|
||||||
_status = status;
|
_status = status;
|
||||||
_responseBody = responseBody;
|
_responseBody = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the path and query of the last request received.</summary>
|
||||||
public string? LastRequestUri { get; private set; }
|
public string? LastRequestUri { get; private set; }
|
||||||
|
/// <summary>Gets the body of the last request received.</summary>
|
||||||
public string? LastRequestBody { get; private set; }
|
public string? LastRequestBody { get; private set; }
|
||||||
|
/// <summary>Gets the HTTP method of the last request received.</summary>
|
||||||
public string? LastMethod { get; private set; }
|
public string? LastMethod { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
protected override async Task<HttpResponseMessage> SendAsync(
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -95,6 +103,8 @@ public class AuditBackfillCommandTests
|
|||||||
private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z")
|
private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z")
|
||||||
=> JsonSerializer.Serialize(new { rowsUpdated, sentinel, before });
|
=> JsonSerializer.Serialize(new { rowsUpdated, sentinel, before });
|
||||||
|
|
||||||
|
/// <summary>Verifies that a successful backfill returns exit code zero and writes the response to the output.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput()
|
public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput()
|
||||||
{
|
{
|
||||||
@@ -117,6 +127,8 @@ public class AuditBackfillCommandTests
|
|||||||
Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the backfill request is sent to the backfill endpoint path.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunBackfill_RequestUri_ContainsBackfillPath()
|
public async Task RunBackfill_RequestUri_ContainsBackfillPath()
|
||||||
{
|
{
|
||||||
@@ -133,6 +145,8 @@ public class AuditBackfillCommandTests
|
|||||||
Assert.Equal("POST", handler.LastMethod);
|
Assert.Equal("POST", handler.LastMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the backfill request body contains the sentinel and the before timestamp.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore()
|
public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore()
|
||||||
{
|
{
|
||||||
@@ -157,6 +171,8 @@ public class AuditBackfillCommandTests
|
|||||||
Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32());
|
Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an HTTP 403 response yields exit code 2.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunBackfill_Http403_ReturnsExitCode2()
|
public async Task RunBackfill_Http403_ReturnsExitCode2()
|
||||||
{
|
{
|
||||||
@@ -173,6 +189,8 @@ public class AuditBackfillCommandTests
|
|||||||
Assert.Equal(2, exit);
|
Assert.Equal(2, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an HTTP 500 response yields exit code 1.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunBackfill_Http500_ReturnsExitCode1()
|
public async Task RunBackfill_Http500_ReturnsExitCode1()
|
||||||
{
|
{
|
||||||
@@ -193,6 +211,7 @@ public class AuditBackfillCommandTests
|
|||||||
// CLI parsing
|
// CLI parsing
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that the backfill-source-node subcommand exists in the audit command group.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup()
|
public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup()
|
||||||
{
|
{
|
||||||
@@ -201,6 +220,7 @@ public class AuditBackfillCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the before option is required on the backfill-source-node subcommand.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BackfillSourceNode_BeforeOption_IsRequired()
|
public void BackfillSourceNode_BeforeOption_IsRequired()
|
||||||
{
|
{
|
||||||
@@ -209,6 +229,7 @@ public class AuditBackfillCommandTests
|
|||||||
Assert.NotEqual(0, exit);
|
Assert.NotEqual(0, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the help text describes the sentinel and before options.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore()
|
public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore()
|
||||||
{
|
{
|
||||||
@@ -223,6 +244,7 @@ public class AuditBackfillCommandTests
|
|||||||
Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the default sentinel value is unknown.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BackfillSourceNode_DefaultSentinel_IsUnknown()
|
public void BackfillSourceNode_DefaultSentinel_IsUnknown()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class AuditCommandTestHarness
|
internal static class AuditCommandTestHarness
|
||||||
{
|
{
|
||||||
|
/// <summary>Builds a root command with the global options and the audit command tree attached.</summary>
|
||||||
|
/// <returns>The configured root command.</returns>
|
||||||
public static RootCommand BuildRoot()
|
public static RootCommand BuildRoot()
|
||||||
{
|
{
|
||||||
var url = new Option<string>("--url") { Recursive = true };
|
var url = new Option<string>("--url") { Recursive = true };
|
||||||
@@ -33,6 +35,9 @@ internal static class AuditCommandTestHarness
|
|||||||
/// the returned <c>Out</c>/<c>Err</c> strings. Callers must be in the <c>Console</c>
|
/// the returned <c>Out</c>/<c>Err</c> strings. Callers must be in the <c>Console</c>
|
||||||
/// xUnit collection so the global <see cref="Console"/> redirect is not racy.
|
/// xUnit collection so the global <see cref="Console"/> redirect is not racy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="root">The root command to parse against.</param>
|
||||||
|
/// <param name="args">The command-line arguments to parse and invoke.</param>
|
||||||
|
/// <returns>The exit code and the captured standard output and error text.</returns>
|
||||||
public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args)
|
public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args)
|
||||||
{
|
{
|
||||||
var output = new StringWriter();
|
var output = new StringWriter();
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log #23 M8-T1).
|
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log).
|
||||||
/// Verifies the parent command exists with its subcommands and that every leaf
|
/// Verifies the parent command exists with its subcommands and that every leaf
|
||||||
/// has an action wired. Updated for M5.6 T5 to cover <c>backfill-source-node</c>.
|
/// has an action wired. Also covers <c>backfill-source-node</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditCommandsScaffoldTests
|
public class AuditCommandsScaffoldTests
|
||||||
{
|
{
|
||||||
@@ -18,6 +18,7 @@ public class AuditCommandsScaffoldTests
|
|||||||
private static Command BuildAudit()
|
private static Command BuildAudit()
|
||||||
=> AuditCommands.Build(Url, Format, Username, Password);
|
=> AuditCommands.Build(Url, Format, Username, Password);
|
||||||
|
|
||||||
|
/// <summary>Verifies that the command group is named <c>audit</c>.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Audit_Command_IsNamedAudit()
|
public void Audit_Command_IsNamedAudit()
|
||||||
{
|
{
|
||||||
@@ -26,6 +27,7 @@ public class AuditCommandsScaffoldTests
|
|||||||
Assert.False(string.IsNullOrWhiteSpace(audit.Description));
|
Assert.False(string.IsNullOrWhiteSpace(audit.Description));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the audit group exposes the query, export, tree, verify-chain and backfill-source-node subcommands.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode()
|
public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode()
|
||||||
{
|
{
|
||||||
@@ -36,6 +38,7 @@ public class AuditCommandsScaffoldTests
|
|||||||
names);
|
names);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the audit help text lists every subcommand.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Audit_HelpText_ListsAllSubcommands()
|
public void Audit_HelpText_ListsAllSubcommands()
|
||||||
{
|
{
|
||||||
@@ -55,6 +58,7 @@ public class AuditCommandsScaffoldTests
|
|||||||
Assert.Contains("backfill-source-node", text);
|
Assert.Contains("backfill-source-node", text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that every leaf command in the audit group has an action wired.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Audit_EveryLeafCommand_HasAnAction()
|
public void Audit_EveryLeafCommand_HasAnAction()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the <c>audit-log</c> → <c>audit-config</c> rename (Audit Log #23 M8-T7):
|
/// Tests for the <c>audit-log</c> → <c>audit-config</c> rename:
|
||||||
/// the new name parses, the deprecated <c>audit-log</c> alias still resolves the full
|
/// the new name parses, the deprecated <c>audit-log</c> alias still resolves the full
|
||||||
/// subcommand tree and emits a stderr deprecation warning, and the renamed group does
|
/// subcommand tree and emits a stderr deprecation warning, and the renamed group does
|
||||||
/// not collide with the distinct <c>audit</c> group from Bundle A.
|
/// not collide with the distinct <c>audit</c> group from Bundle A.
|
||||||
@@ -28,6 +28,7 @@ public class AuditConfigDeprecationTests
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the audit-config query command works.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditConfig_Query_Works()
|
public void AuditConfig_Query_Works()
|
||||||
{
|
{
|
||||||
@@ -37,6 +38,7 @@ public class AuditConfigDeprecationTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the legacy audit-log query command still works but emits a deprecation warning to stderr.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr()
|
public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr()
|
||||||
{
|
{
|
||||||
@@ -54,6 +56,7 @@ public class AuditConfigDeprecationTests
|
|||||||
Assert.Contains("audit-config", warning);
|
Assert.Contains("audit-config", warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that no deprecation warning is emitted when the new command name is used.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DeprecationWarning_NotEmitted_ForNewName()
|
public void DeprecationWarning_NotEmitted_ForNewName()
|
||||||
{
|
{
|
||||||
@@ -64,6 +67,7 @@ public class AuditConfigDeprecationTests
|
|||||||
Assert.Equal("", stderr.ToString());
|
Assert.Equal("", stderr.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that no deprecation warning is emitted for an unrelated command.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DeprecationWarning_NotEmitted_ForUnrelatedCommand()
|
public void DeprecationWarning_NotEmitted_ForUnrelatedCommand()
|
||||||
{
|
{
|
||||||
@@ -73,6 +77,7 @@ public class AuditConfigDeprecationTests
|
|||||||
Assert.Equal("", stderr.ToString());
|
Assert.Equal("", stderr.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the audit and audit-config groups are distinct commands that do not conflict.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict()
|
public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log #23 M8-T3):
|
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log):
|
||||||
/// required-flag enforcement, query-string construction, streaming the response body
|
/// required-flag enforcement, query-string construction, streaming the response body
|
||||||
/// to the output file, and the parquet-not-implemented (501) path.
|
/// to the output file, and the parquet-not-implemented (501) path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -17,6 +17,7 @@ public class AuditExportCommandTests
|
|||||||
{
|
{
|
||||||
// ---- CLI parsing: required flags --------------------------------------
|
// ---- CLI parsing: required flags --------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that omitting a required flag produces a parse error and a non-zero exit code.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
|
public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,7 @@ public class AuditExportCommandTests
|
|||||||
Assert.Contains("--output", err);
|
Assert.Contains("--output", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the command parses without error when all required flags are present.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
|
public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
|
||||||
{
|
{
|
||||||
@@ -40,6 +42,7 @@ public class AuditExportCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an invalid format value is not rejected at the parse layer.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer()
|
public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer()
|
||||||
{
|
{
|
||||||
@@ -56,6 +59,8 @@ public class AuditExportCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the format value is accepted in any letter case.</summary>
|
||||||
|
/// <param name="format">The format value to parse, in varying case.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("CSV")]
|
[InlineData("CSV")]
|
||||||
[InlineData("Csv")]
|
[InlineData("Csv")]
|
||||||
@@ -73,6 +78,7 @@ public class AuditExportCommandTests
|
|||||||
|
|
||||||
// ---- Query string -----------------------------------------------------
|
// ---- Query string -----------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the query string includes the time window, the format and the filters.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_IncludesWindowFormatAndFilters()
|
public void BuildQueryString_IncludesWindowFormatAndFilters()
|
||||||
{
|
{
|
||||||
@@ -96,6 +102,7 @@ public class AuditExportCommandTests
|
|||||||
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that multi-value filters emit one query key per value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
|
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
|
||||||
{
|
{
|
||||||
@@ -120,6 +127,7 @@ public class AuditExportCommandTests
|
|||||||
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that multi-value filters that were not set are omitted from the query string.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_OmitsUnsetMultiValueFilters()
|
public void BuildQueryString_OmitsUnsetMultiValueFilters()
|
||||||
{
|
{
|
||||||
@@ -140,6 +148,7 @@ public class AuditExportCommandTests
|
|||||||
Assert.Null(parsed["sourceSiteId"]);
|
Assert.Null(parsed["sourceSiteId"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that multiple channel values supplied in a single token are accepted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Export_MultipleChannelValues_SingleToken_AreAccepted()
|
public void Export_MultipleChannelValues_SingleToken_AreAccepted()
|
||||||
{
|
{
|
||||||
@@ -154,6 +163,7 @@ public class AuditExportCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that multiple channel values supplied through a repeated flag are accepted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
||||||
{
|
{
|
||||||
@@ -167,6 +177,7 @@ public class AuditExportCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a multi-value channel option still parses when one name is unrecognised.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
||||||
{
|
{
|
||||||
@@ -188,14 +199,19 @@ public class AuditExportCommandTests
|
|||||||
{
|
{
|
||||||
private readonly HttpStatusCode _status;
|
private readonly HttpStatusCode _status;
|
||||||
private readonly Func<HttpContent> _content;
|
private readonly Func<HttpContent> _content;
|
||||||
|
/// <summary>Gets the path and query string of the last request the handler received.</summary>
|
||||||
public string? RequestPathAndQuery { get; private set; }
|
public string? RequestPathAndQuery { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Initializes a handler that answers every request with the given status and content.</summary>
|
||||||
|
/// <param name="status">The HTTP status code to return.</param>
|
||||||
|
/// <param name="content">A factory that creates the response content.</param>
|
||||||
public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
|
public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
|
||||||
{
|
{
|
||||||
_status = status;
|
_status = status;
|
||||||
_content = content;
|
_content = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -204,6 +220,8 @@ public class AuditExportCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a successful export streams the response body to the output file.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_Success_StreamsResponseToOutputFile()
|
public async Task RunExport_Success_StreamsResponseToOutputFile()
|
||||||
{
|
{
|
||||||
@@ -234,11 +252,13 @@ public class AuditExportCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_Http403_ReturnsExitCode2()
|
public async Task RunExport_Http403_ReturnsExitCode2()
|
||||||
{
|
{
|
||||||
// CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the
|
// An HTTP 403 on /api/audit/export must produce exit code 2 per the
|
||||||
// documented CLI contract — the legacy bare-1 return masked auth failures
|
// documented CLI contract — the legacy plain exit code 1 masked auth failures
|
||||||
// as generic command failures.
|
// as generic command failures.
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
|
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
|
||||||
try
|
try
|
||||||
@@ -262,6 +282,8 @@ public class AuditExportCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unauthorized error code on a response whose status is not 403 also returns exit code 2.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
||||||
{
|
{
|
||||||
@@ -286,6 +308,8 @@ public class AuditExportCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a parquet 501 response prints the server message and returns a non-zero exit code.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
|
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
|
||||||
{
|
{
|
||||||
@@ -312,6 +336,8 @@ public class AuditExportCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a large response body is streamed to the file rather than fully buffered.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
|
public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
|
||||||
{
|
{
|
||||||
@@ -351,18 +377,27 @@ public class AuditExportCommandTests
|
|||||||
private readonly byte _value;
|
private readonly byte _value;
|
||||||
private long _remaining;
|
private long _remaining;
|
||||||
|
|
||||||
|
/// <summary>Initializes a stream that yields the given byte repeatedly.</summary>
|
||||||
|
/// <param name="value">The byte value to repeat.</param>
|
||||||
|
/// <param name="length">The total number of bytes the stream yields.</param>
|
||||||
public RepeatingStream(byte value, long length)
|
public RepeatingStream(byte value, long length)
|
||||||
{
|
{
|
||||||
_value = value;
|
_value = value;
|
||||||
_remaining = length;
|
_remaining = length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanRead => true;
|
public override bool CanRead => true;
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanSeek => false;
|
public override bool CanSeek => false;
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanWrite => false;
|
public override bool CanWrite => false;
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Length => throw new NotSupportedException();
|
public override long Length => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override int Read(byte[] buffer, int offset, int count)
|
public override int Read(byte[] buffer, int offset, int count)
|
||||||
{
|
{
|
||||||
if (_remaining <= 0) return 0;
|
if (_remaining <= 0) return 0;
|
||||||
@@ -372,14 +407,18 @@ public class AuditExportCommandTests
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override void Flush() { }
|
public override void Flush() { }
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override void SetLength(long value) => throw new NotSupportedException();
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// Deadline + Ctrl-C cancellation (arch-review 2026-08-19 P6-8: audit export
|
// Deadline + Ctrl-C cancellation (audit export
|
||||||
// was the one CLI HTTP path with NO deadline — a hung server hung the CLI
|
// was the one CLI HTTP path with NO deadline — a hung server hung the CLI
|
||||||
// forever). Every test below bounds its own wait well under what the old,
|
// forever). Every test below bounds its own wait well under what the old,
|
||||||
// deadline-less implementation would have done (indefinite hang) — a
|
// deadline-less implementation would have done (indefinite hang) — a
|
||||||
@@ -389,8 +428,10 @@ public class AuditExportCommandTests
|
|||||||
/// <summary>A handler that never completes the request until its <see cref="CancellationToken"/> fires — simulates a server that accepted the connection but never answers.</summary>
|
/// <summary>A handler that never completes the request until its <see cref="CancellationToken"/> fires — simulates a server that accepted the connection but never answers.</summary>
|
||||||
private sealed class HangingHandler : HttpMessageHandler
|
private sealed class HangingHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the number of requests the handler has received.</summary>
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
protected override async Task<HttpResponseMessage> SendAsync(
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -410,17 +451,26 @@ public class AuditExportCommandTests
|
|||||||
private readonly byte[] _firstChunk;
|
private readonly byte[] _firstChunk;
|
||||||
private bool _servedFirstChunk;
|
private bool _servedFirstChunk;
|
||||||
|
|
||||||
|
/// <summary>Initializes a stream that serves the given first chunk and then stalls.</summary>
|
||||||
|
/// <param name="firstChunk">The bytes served before the stream goes silent.</param>
|
||||||
public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk;
|
public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanRead => true;
|
public override bool CanRead => true;
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanSeek => false;
|
public override bool CanSeek => false;
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanWrite => false;
|
public override bool CanWrite => false;
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Length => throw new NotSupportedException();
|
public override long Length => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override int Read(byte[] buffer, int offset, int count) =>
|
public override int Read(byte[] buffer, int offset, int count) =>
|
||||||
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
|
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!_servedFirstChunk)
|
if (!_servedFirstChunk)
|
||||||
@@ -435,9 +485,13 @@ public class AuditExportCommandTests
|
|||||||
return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation
|
return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override void Flush() { }
|
public override void Flush() { }
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override void SetLength(long value) => throw new NotSupportedException();
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,17 +501,26 @@ public class AuditExportCommandTests
|
|||||||
private readonly byte[] _firstChunk;
|
private readonly byte[] _firstChunk;
|
||||||
private bool _servedFirstChunk;
|
private bool _servedFirstChunk;
|
||||||
|
|
||||||
|
/// <summary>Initializes a stream that serves the given first chunk and then throws.</summary>
|
||||||
|
/// <param name="firstChunk">The bytes served before the stream faults.</param>
|
||||||
public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk;
|
public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanRead => true;
|
public override bool CanRead => true;
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanSeek => false;
|
public override bool CanSeek => false;
|
||||||
|
/// <inheritdoc />
|
||||||
public override bool CanWrite => false;
|
public override bool CanWrite => false;
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Length => throw new NotSupportedException();
|
public override long Length => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override int Read(byte[] buffer, int offset, int count) =>
|
public override int Read(byte[] buffer, int offset, int count) =>
|
||||||
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
|
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!_servedFirstChunk)
|
if (!_servedFirstChunk)
|
||||||
@@ -471,9 +534,13 @@ public class AuditExportCommandTests
|
|||||||
throw new IOException("connection reset by peer (simulated)");
|
throw new IOException("connection reset by peer (simulated)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public override void Flush() { }
|
public override void Flush() { }
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override void SetLength(long value) => throw new NotSupportedException();
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +561,8 @@ public class AuditExportCommandTests
|
|||||||
return capture.ToString();
|
return capture.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a server that never sends response headers causes a timeout with a friendly error message.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError()
|
public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError()
|
||||||
{
|
{
|
||||||
@@ -521,6 +590,8 @@ public class AuditExportCommandTests
|
|||||||
Assert.False(File.Exists(path));
|
Assert.False(File.Exists(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the inactivity timeout fires when the stream stalls mid-transfer.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_StreamStallsMidTransfer_InactivityTimeoutFires()
|
public async Task RunExport_StreamStallsMidTransfer_InactivityTimeoutFires()
|
||||||
{
|
{
|
||||||
@@ -562,6 +633,8 @@ public class AuditExportCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an I/O exception during streaming produces a clean message rather than a stack trace.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_MidStreamIOException_ProducesCleanMessageNotStackTrace()
|
public async Task RunExport_MidStreamIOException_ProducesCleanMessageNotStackTrace()
|
||||||
{
|
{
|
||||||
@@ -596,6 +669,8 @@ public class AuditExportCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a Ctrl-C cancellation token cancels the export promptly with a clean message.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunExport_CtrlCToken_CancelsPromptlyWithCleanMessage()
|
public async Task RunExport_CtrlCToken_CancelsPromptlyWithCleanMessage()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the <c>scadabridge audit query</c> subcommand (Audit Log #23 M8-T2):
|
/// Tests for the <c>scadabridge audit query</c> subcommand:
|
||||||
/// time-spec resolution, query-string construction, formatter selection, error
|
/// time-spec resolution, query-string construction, formatter selection, error
|
||||||
/// handling, and keyset-cursor paging via <c>--all</c>.
|
/// handling, and keyset-cursor paging via <c>--all</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -17,6 +17,7 @@ public class AuditQueryCommandTests
|
|||||||
{
|
{
|
||||||
// ---- Time-spec parsing -------------------------------------------------
|
// ---- Time-spec parsing -------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a relative hours time spec resolves to the current time minus that offset.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset()
|
public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset()
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved);
|
Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a relative days time spec resolves to the current time minus that offset.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset()
|
public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset()
|
||||||
{
|
{
|
||||||
@@ -33,6 +35,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved);
|
Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an absolute ISO 8601 time spec is parsed verbatim.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim()
|
public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim()
|
||||||
{
|
{
|
||||||
@@ -41,6 +44,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved);
|
Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unparseable time spec throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveTimeSpec_Garbage_Throws()
|
public void ResolveTimeSpec_Garbage_Throws()
|
||||||
{
|
{
|
||||||
@@ -50,6 +54,7 @@ public class AuditQueryCommandTests
|
|||||||
|
|
||||||
// ---- Query string construction ----------------------------------------
|
// ---- Query string construction ----------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a fully populated flag set produces the expected query-string parameters.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_FullFlagSet_ProducesExpectedParameters()
|
public void BuildQueryString_FullFlagSet_ProducesExpectedParameters()
|
||||||
{
|
{
|
||||||
@@ -90,6 +95,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the errors-only flag maps to the failed status filter.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_ErrorsOnly_MapsToFailedStatus()
|
public void BuildQueryString_ErrorsOnly_MapsToFailedStatus()
|
||||||
{
|
{
|
||||||
@@ -100,6 +106,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("Failed", parsed["status"]);
|
Assert.Equal("Failed", parsed["status"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a multi-value channel filter emits one query key per value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
|
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
|
||||||
{
|
{
|
||||||
@@ -119,6 +126,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the errors-only flag overrides any explicit status values.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
|
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
|
||||||
{
|
{
|
||||||
@@ -137,6 +145,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
|
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the paging cursor is appended after the filter parameters.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_Cursor_AppendsAfterParameters()
|
public void BuildQueryString_Cursor_AppendsAfterParameters()
|
||||||
{
|
{
|
||||||
@@ -149,6 +158,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]);
|
Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that unset filters are omitted from the query string.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_OmitsUnsetFilters()
|
public void BuildQueryString_OmitsUnsetFilters()
|
||||||
{
|
{
|
||||||
@@ -165,6 +175,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("100", parsed["pageSize"]);
|
Assert.Equal("100", parsed["pageSize"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an execution id filter emits the execution id parameter.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
|
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
|
||||||
{
|
{
|
||||||
@@ -176,6 +187,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
|
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a parent execution id filter emits the parent execution id parameter.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
|
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
|
||||||
{
|
{
|
||||||
@@ -192,13 +204,17 @@ public class AuditQueryCommandTests
|
|||||||
private sealed class RecordingHandler : HttpMessageHandler
|
private sealed class RecordingHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
private readonly Queue<string> _bodies;
|
private readonly Queue<string> _bodies;
|
||||||
|
/// <summary>The request URIs received by the handler, in order.</summary>
|
||||||
public List<string> RequestUris { get; } = new();
|
public List<string> RequestUris { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Creates a handler that replies with the given JSON bodies in sequence.</summary>
|
||||||
|
/// <param name="bodies">The response bodies to return, one per request.</param>
|
||||||
public RecordingHandler(params string[] bodies)
|
public RecordingHandler(params string[] bodies)
|
||||||
{
|
{
|
||||||
_bodies = new Queue<string>(bodies);
|
_bodies = new Queue<string>(bodies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -211,6 +227,8 @@ public class AuditQueryCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a single page of results is written as JSON lines.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunQuery_SinglePage_WritesEventsAsJsonLines()
|
public async Task RunQuery_SinglePage_WritesEventsAsJsonLines()
|
||||||
{
|
{
|
||||||
@@ -231,6 +249,8 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Single(handler.RequestUris);
|
Assert.Single(handler.RequestUris);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the all flag follows the next cursor across pages.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages()
|
public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages()
|
||||||
{
|
{
|
||||||
@@ -251,6 +271,8 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal(2, lines.Length);
|
Assert.Equal(2, lines.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that without the all flag only the first page is fetched even when a cursor is present.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent()
|
public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent()
|
||||||
{
|
{
|
||||||
@@ -267,6 +289,8 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Single(handler.RequestUris);
|
Assert.Single(handler.RequestUris);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a server error returns a non-zero exit code.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunQuery_ServerError_ReturnsNonZeroExit()
|
public async Task RunQuery_ServerError_ReturnsNonZeroExit()
|
||||||
{
|
{
|
||||||
@@ -281,12 +305,14 @@ public class AuditQueryCommandTests
|
|||||||
Assert.NotEqual(0, exit);
|
Assert.NotEqual(0, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunQuery_Http403_ReturnsExitCode2()
|
public async Task RunQuery_Http403_ReturnsExitCode2()
|
||||||
{
|
{
|
||||||
// CLI-018: an authorization failure on /api/audit/query (HTTP 403) must
|
// An authorization failure on /api/audit/query (HTTP 403) must
|
||||||
// produce exit code 2 per the documented CLI exit-code contract — the
|
// produce exit code 2 per the documented CLI exit-code contract — the
|
||||||
// legacy bare-1 return masked auth failures as generic command failures.
|
// legacy bare exit code 1 return masked auth failures as generic command failures.
|
||||||
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
||||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||||
var output = new StringWriter();
|
var output = new StringWriter();
|
||||||
@@ -298,11 +324,13 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal(2, exit);
|
Assert.Equal(2, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an UNAUTHORIZED error code on a status other than 403 returns exit code 2.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
|
||||||
{
|
{
|
||||||
// The server may signal authorization failure via the error code on a
|
// The server may signal authorization failure via the error code on a
|
||||||
// non-403 status (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
|
// status other than 403 (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
|
||||||
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
||||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||||
var output = new StringWriter();
|
var output = new StringWriter();
|
||||||
@@ -314,10 +342,12 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal(2, exit);
|
Assert.Equal(2, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a generic server error returns exit code 1.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunQuery_GenericServerError_ReturnsExitCode1()
|
public async Task RunQuery_GenericServerError_ReturnsExitCode1()
|
||||||
{
|
{
|
||||||
// Authentication / internal errors (non-403, no auth code) must remain
|
// Authentication / internal errors (status other than 403, no auth code) must remain
|
||||||
// exit code 1 — exit 2 is reserved for authorization failures.
|
// exit code 1 — exit 2 is reserved for authorization failures.
|
||||||
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
|
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
|
||||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||||
@@ -332,6 +362,7 @@ public class AuditQueryCommandTests
|
|||||||
|
|
||||||
private sealed class ErrorHandler : HttpMessageHandler
|
private sealed class ErrorHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||||
@@ -344,7 +375,11 @@ public class AuditQueryCommandTests
|
|||||||
{
|
{
|
||||||
private readonly HttpStatusCode _status;
|
private readonly HttpStatusCode _status;
|
||||||
private readonly string _body;
|
private readonly string _body;
|
||||||
|
/// <summary>Creates a handler that always replies with the given status and body.</summary>
|
||||||
|
/// <param name="status">The HTTP status code to return.</param>
|
||||||
|
/// <param name="body">The response body to return.</param>
|
||||||
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
|
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
|
||||||
|
/// <inheritdoc />
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||||
@@ -352,6 +387,7 @@ public class AuditQueryCommandTests
|
|||||||
|
|
||||||
// ---- CLI parsing -------------------------------------------------------
|
// ---- CLI parsing -------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unknown flag produces a parse error and a non-zero exit code.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit()
|
public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit()
|
||||||
{
|
{
|
||||||
@@ -361,6 +397,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.NotEqual("", err);
|
Assert.NotEqual("", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the table output format is accepted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Query_FormatTable_IsAccepted()
|
public void Query_FormatTable_IsAccepted()
|
||||||
{
|
{
|
||||||
@@ -369,6 +406,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the execution id option is accepted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Query_ExecutionIdOption_IsAccepted()
|
public void Query_ExecutionIdOption_IsAccepted()
|
||||||
{
|
{
|
||||||
@@ -381,6 +419,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the parent execution id option is accepted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Query_ParentExecutionIdOption_IsAccepted()
|
public void Query_ParentExecutionIdOption_IsAccepted()
|
||||||
{
|
{
|
||||||
@@ -395,6 +434,7 @@ public class AuditQueryCommandTests
|
|||||||
|
|
||||||
// ---- Enum-name validation (fast-fail) ----------------------------------
|
// ---- Enum-name validation (fast-fail) ----------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a channel option using a real enum name is accepted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Query_ChannelWithRealEnumName_IsAccepted()
|
public void Query_ChannelWithRealEnumName_IsAccepted()
|
||||||
{
|
{
|
||||||
@@ -403,6 +443,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that multiple channel values in a single token are accepted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
|
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
|
||||||
{
|
{
|
||||||
@@ -412,6 +453,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that multiple channel values given by repeating the flag are accepted.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
|
||||||
{
|
{
|
||||||
@@ -438,6 +480,9 @@ public class AuditQueryCommandTests
|
|||||||
// carried through to the request rather than rejected locally.
|
// carried through to the request rather than rejected locally.
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unrecognised filter value is not rejected at the parse layer.</summary>
|
||||||
|
/// <param name="option">The filter option name.</param>
|
||||||
|
/// <param name="value">The filter value to pass.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound"
|
[InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound"
|
||||||
[InlineData("--kind", "CachedCall")]
|
[InlineData("--kind", "CachedCall")]
|
||||||
@@ -449,6 +494,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a multi-value channel filter containing one unrecognised name still parses.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
||||||
{
|
{
|
||||||
@@ -459,6 +505,9 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a filter value in any letter case is accepted.</summary>
|
||||||
|
/// <param name="option">The filter option name.</param>
|
||||||
|
/// <param name="value">The filter value to pass.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("--channel", "apioutbound")]
|
[InlineData("--channel", "apioutbound")]
|
||||||
[InlineData("--kind", "apicall")]
|
[InlineData("--kind", "apicall")]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the <c>table</c> output formatter of the <c>scadabridge audit query</c>
|
/// Tests for the <c>table</c> output formatter of the <c>scadabridge audit query</c>
|
||||||
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the
|
/// subcommand: header rendering, long-field truncation, the
|
||||||
/// empty-result-set case, and null-actor handling.
|
/// empty-result-set case, and null-actor handling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditTableFormatterTests
|
public class AuditTableFormatterTests
|
||||||
@@ -18,6 +18,7 @@ public class AuditTableFormatterTests
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the table renders a header row with the expected columns.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Table_RendersHeaderRow_WithExpectedColumns()
|
public void Table_RendersHeaderRow_WithExpectedColumns()
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,7 @@ public class AuditTableFormatterTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a long target is truncated with an ellipsis.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Table_TruncatesLongTarget_WithEllipsis()
|
public void Table_TruncatesLongTarget_WithEllipsis()
|
||||||
{
|
{
|
||||||
@@ -57,6 +59,7 @@ public class AuditTableFormatterTests
|
|||||||
Assert.DoesNotContain(longTarget, text);
|
Assert.DoesNotContain(longTarget, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an empty result set renders only the header or a no-rows message.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
|
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
|
||||||
{
|
{
|
||||||
@@ -73,6 +76,7 @@ public class AuditTableFormatterTests
|
|||||||
Assert.Contains("OccurredAtUtc", lines[0]);
|
Assert.Contains("OccurredAtUtc", lines[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a null actor renders as a blank cell.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Table_NullActor_RendersBlank()
|
public void Table_NullActor_RendersBlank()
|
||||||
{
|
{
|
||||||
@@ -93,6 +97,7 @@ public class AuditTableFormatterTests
|
|||||||
Assert.Contains("InboundApi", lines[1]);
|
Assert.Contains("InboundApi", lines[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the header is emitted once per page and the data rows are aligned.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
|
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the <c>scadabridge audit tree</c> subcommand (Audit Log #23 M5.1-T8):
|
/// Tests for the <c>scadabridge audit tree</c> subcommand:
|
||||||
/// tree rendering (table format), JSON output, error handling, and CLI parsing.
|
/// tree rendering (table format), JSON output, error handling, and CLI parsing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("Console")]
|
[Collection("Console")]
|
||||||
@@ -58,6 +58,7 @@ public class AuditTreeCommandTests
|
|||||||
// ParseNodes
|
// ParseNodes
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that parsing a valid JSON array returns the matching node DTOs.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseNodes_ValidArray_ReturnsDtos()
|
public void ParseNodes_ValidArray_ReturnsDtos()
|
||||||
{
|
{
|
||||||
@@ -75,6 +76,7 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Equal(3, nodes[0].RowCount);
|
Assert.Equal(3, nodes[0].RowCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that parsing an empty JSON array returns an empty list.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseNodes_EmptyArray_ReturnsEmpty()
|
public void ParseNodes_EmptyArray_ReturnsEmpty()
|
||||||
{
|
{
|
||||||
@@ -82,6 +84,7 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Empty(nodes);
|
Assert.Empty(nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that parsing invalid JSON returns an empty list.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseNodes_InvalidJson_ReturnsEmpty()
|
public void ParseNodes_InvalidJson_ReturnsEmpty()
|
||||||
{
|
{
|
||||||
@@ -93,6 +96,7 @@ public class AuditTreeCommandTests
|
|||||||
// WriteTable — ASCII tree rendering
|
// WriteTable — ASCII tree rendering
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that writing a table for no nodes prints the fallback message.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WriteTable_EmptyNodes_PrintsFallbackMessage()
|
public void WriteTable_EmptyNodes_PrintsFallbackMessage()
|
||||||
{
|
{
|
||||||
@@ -101,6 +105,7 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Contains("no execution tree found", output.ToString());
|
Assert.Contains("no execution tree found", output.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that writing a table for a single root node prints it with no indent.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WriteTable_SingleRootNode_PrintsWithNoIndent()
|
public void WriteTable_SingleRootNode_PrintsWithNoIndent()
|
||||||
{
|
{
|
||||||
@@ -117,6 +122,7 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Contains("[*]", line); // queried node marked
|
Assert.Contains("[*]", line); // queried node marked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that writing a table for a multi-level tree indents each child level correctly.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly()
|
public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly()
|
||||||
{
|
{
|
||||||
@@ -144,6 +150,7 @@ public class AuditTreeCommandTests
|
|||||||
Assert.StartsWith(" ", grandLine);
|
Assert.StartsWith(" ", grandLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that writing a table marks the queried node and leaves the others unmarked.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WriteTable_QueriedNodeIsMarked_OthersAreNot()
|
public void WriteTable_QueriedNodeIsMarked_OthersAreNot()
|
||||||
{
|
{
|
||||||
@@ -167,6 +174,7 @@ public class AuditTreeCommandTests
|
|||||||
// WriteJson
|
// WriteJson
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that writing JSON for valid nodes emits a valid JSON array.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WriteJson_ValidNodes_EmitsValidJsonArray()
|
public void WriteJson_ValidNodes_EmitsValidJsonArray()
|
||||||
{
|
{
|
||||||
@@ -183,6 +191,7 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that writing JSON for no nodes emits an empty array.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WriteJson_EmptyNodes_EmitsEmptyArray()
|
public void WriteJson_EmptyNodes_EmitsEmptyArray()
|
||||||
{
|
{
|
||||||
@@ -204,14 +213,19 @@ public class AuditTreeCommandTests
|
|||||||
private readonly HttpStatusCode _status;
|
private readonly HttpStatusCode _status;
|
||||||
private readonly string _body;
|
private readonly string _body;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <c>FixedHandler</c> class.</summary>
|
||||||
|
/// <param name="status">The status.</param>
|
||||||
|
/// <param name="body">The body.</param>
|
||||||
public FixedHandler(HttpStatusCode status, string body)
|
public FixedHandler(HttpStatusCode status, string body)
|
||||||
{
|
{
|
||||||
_status = status;
|
_status = status;
|
||||||
_body = body;
|
_body = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the last request uri value.</summary>
|
||||||
public string? LastRequestUri { get; private set; }
|
public string? LastRequestUri { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -223,6 +237,8 @@ public class AuditTreeCommandTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a successful tree run returns exit code zero and writes the output.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunTree_Success_ReturnsZeroAndWritesOutput()
|
public async Task RunTree_Success_ReturnsZeroAndWritesOutput()
|
||||||
{
|
{
|
||||||
@@ -239,6 +255,8 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Contains(rootId, output.ToString());
|
Assert.Contains(rootId, output.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tree run with an empty response returns exit code zero and prints the fallback message.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage()
|
public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage()
|
||||||
{
|
{
|
||||||
@@ -253,6 +271,8 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Contains("no execution tree found", output.ToString());
|
Assert.Contains("no execution tree found", output.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tree run with the JSON format emits valid JSON.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunTree_JsonFormat_EmitsValidJson()
|
public async Task RunTree_JsonFormat_EmitsValidJson()
|
||||||
{
|
{
|
||||||
@@ -269,6 +289,8 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tree run that receives HTTP 403 returns exit code 2.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunTree_Http403_ReturnsExitCode2()
|
public async Task RunTree_Http403_ReturnsExitCode2()
|
||||||
{
|
{
|
||||||
@@ -282,6 +304,8 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Equal(2, exit);
|
Assert.Equal(2, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tree run that receives HTTP 500 returns exit code 1.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunTree_Http500_ReturnsExitCode1()
|
public async Task RunTree_Http500_ReturnsExitCode1()
|
||||||
{
|
{
|
||||||
@@ -295,6 +319,8 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Equal(1, exit);
|
Assert.Equal(1, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the tree request URL contains the execution id.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunTree_RequestUrlContainsExecutionId()
|
public async Task RunTree_RequestUrlContainsExecutionId()
|
||||||
{
|
{
|
||||||
@@ -313,6 +339,7 @@ public class AuditTreeCommandTests
|
|||||||
// CLI parsing — audit tree subcommand
|
// CLI parsing — audit tree subcommand
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that the tree subcommand exists in the audit command group.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tree_Subcommand_ExistsInAuditCommandGroup()
|
public void Tree_Subcommand_ExistsInAuditCommandGroup()
|
||||||
{
|
{
|
||||||
@@ -322,6 +349,7 @@ public class AuditTreeCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the tree execution id option is required.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tree_ExecutionIdOption_IsRequired()
|
public void Tree_ExecutionIdOption_IsRequired()
|
||||||
{
|
{
|
||||||
@@ -332,6 +360,7 @@ public class AuditTreeCommandTests
|
|||||||
Assert.NotEqual(0, exit);
|
Assert.NotEqual(0, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the tree help text describes the execution id.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tree_HelpText_DescribesExecutionId()
|
public void Tree_HelpText_DescribesExecutionId()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand (Audit Log #23 M8-T4).
|
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand.
|
||||||
/// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled"
|
/// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled"
|
||||||
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
|
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("Console")]
|
[Collection("Console")]
|
||||||
public class AuditVerifyChainCommandTests
|
public class AuditVerifyChainCommandTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that verifying the chain for a valid month exits with zero and prints the documented message.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
|
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,7 @@ public class AuditVerifyChainCommandTests
|
|||||||
Assert.Contains("Component-AuditLog.md", output);
|
Assert.Contains("Component-AuditLog.md", output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a malformed month value makes the command exit with a non-zero code.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VerifyChain_MalformedMonth_ExitsNonZero()
|
public void VerifyChain_MalformedMonth_ExitsNonZero()
|
||||||
{
|
{
|
||||||
@@ -32,6 +34,7 @@ public class AuditVerifyChainCommandTests
|
|||||||
Assert.NotEqual(0, exit);
|
Assert.NotEqual(0, exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that omitting the month option produces the required-option error.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VerifyChain_MissingMonth_ProducesRequiredFlagError()
|
public void VerifyChain_MissingMonth_ProducesRequiredFlagError()
|
||||||
{
|
{
|
||||||
@@ -42,6 +45,9 @@ public class AuditVerifyChainCommandTests
|
|||||||
Assert.Contains("--month", err);
|
Assert.Contains("--month", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that month validation accepts only values in <c>yyyy-MM</c> format.</summary>
|
||||||
|
/// <param name="month">The month string to validate.</param>
|
||||||
|
/// <param name="expected">Whether the month is expected to be valid.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("2026-05", true)]
|
[InlineData("2026-05", true)]
|
||||||
[InlineData("2026-01", true)]
|
[InlineData("2026-01", true)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CLI-019 regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
|
/// Regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
|
||||||
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
|
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
|
||||||
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new
|
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new
|
||||||
/// streaming helper decodes the base64 string in fixed-size chunks straight into
|
/// streaming helper decodes the base64 string in fixed-size chunks straight into
|
||||||
@@ -15,11 +15,13 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly string _tempPath;
|
private readonly string _tempPath;
|
||||||
|
|
||||||
|
/// <summary>Creates a unique temporary output path for each test.</summary>
|
||||||
public BundleCommandsStreamingTests()
|
public BundleCommandsStreamingTests()
|
||||||
{
|
{
|
||||||
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
|
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the resources used by this instance.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (File.Exists(_tempPath))
|
if (File.Exists(_tempPath))
|
||||||
@@ -28,6 +30,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a small payload is written to the file and read back unchanged.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StreamBase64ToFile_SmallPayload_RoundTrips()
|
public void StreamBase64ToFile_SmallPayload_RoundTrips()
|
||||||
{
|
{
|
||||||
@@ -41,6 +44,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Equal(bytes, roundTripped);
|
Assert.Equal(bytes, roundTripped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a payload spanning several chunks, with a short final chunk, round-trips unchanged.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
|
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
|
||||||
{
|
{
|
||||||
@@ -60,6 +64,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Equal(bytes, roundTripped);
|
Assert.Equal(bytes, roundTripped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an empty base64 string produces an empty file.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
|
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
|
||||||
{
|
{
|
||||||
@@ -70,6 +75,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Empty(File.ReadAllBytes(_tempPath));
|
Assert.Empty(File.ReadAllBytes(_tempPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that invalid base64 text throws a <see cref="FormatException"/>.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
|
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
|
||||||
{
|
{
|
||||||
@@ -81,20 +87,23 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
|
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a null base64 string throws an <see cref="ArgumentNullException"/>.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StreamBase64ToFile_NullBase64_Throws()
|
public void StreamBase64ToFile_NullBase64_Throws()
|
||||||
{
|
{
|
||||||
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
|
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an empty output path throws an <see cref="ArgumentException"/>.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StreamBase64ToFile_EmptyOutputPath_Throws()
|
public void StreamBase64ToFile_EmptyOutputPath_Throws()
|
||||||
{
|
{
|
||||||
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
|
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- M8 (B4): --sites / --instances comma-split + flag parsing ----------
|
// ---- --sites / --instances comma-split + flag parsing ----------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a comma-separated list is split and each name is trimmed.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseNameList_CommaSeparated_SplitsAndTrims()
|
public void ParseNameList_CommaSeparated_SplitsAndTrims()
|
||||||
{
|
{
|
||||||
@@ -103,6 +112,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result);
|
Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a null or blank token yields a null list.</summary>
|
||||||
|
/// <param name="token">The raw option value to parse.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null)]
|
[InlineData(null)]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
@@ -112,6 +123,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Null(BundleCommands.ParseNameList(token));
|
Assert.Null(BundleCommands.ParseNameList(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that empty and whitespace-only entries are dropped from the list.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseNameList_EmptyEntries_AreDropped()
|
public void ParseNameList_EmptyEntries_AreDropped()
|
||||||
{
|
{
|
||||||
@@ -119,6 +131,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Equal(new[] { "a", "b", "c" }, result);
|
Assert.Equal(new[] { "a", "b", "c" }, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the export command accepts the sites and instances flags without parse errors.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BundleExport_SitesAndInstancesFlags_ParseWithoutError()
|
public void BundleExport_SitesAndInstancesFlags_ParseWithoutError()
|
||||||
{
|
{
|
||||||
@@ -138,14 +151,16 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- M8 (D3): --map-site / --map-connection spec parsing ----------------
|
// ---- --map-site / --map-connection spec parsing ----------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a null mapping list yields no site mappings.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseSiteMappings_Null_ReturnsEmpty()
|
public void ParseSiteMappings_Null_ReturnsEmpty()
|
||||||
{
|
{
|
||||||
Assert.Empty(BundleCommands.ParseSiteMappings(null));
|
Assert.Empty(BundleCommands.ParseSiteMappings(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a source=target spec maps the source site to an existing target site.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseSiteMappings_SrcEqualsDst_MapsToExisting()
|
public void ParseSiteMappings_SrcEqualsDst_MapsToExisting()
|
||||||
{
|
{
|
||||||
@@ -155,6 +170,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Equal("PLANT-A", spec.TargetSiteIdentifier);
|
Assert.Equal("PLANT-A", spec.TargetSiteIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a spec with no target means the site should be created new.</summary>
|
||||||
|
/// <param name="token">The raw site mapping spec.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("NORTH-01")] // no '=' part -> create-new
|
[InlineData("NORTH-01")] // no '=' part -> create-new
|
||||||
[InlineData("NORTH-01=")] // empty rhs -> create-new
|
[InlineData("NORTH-01=")] // empty rhs -> create-new
|
||||||
@@ -167,6 +184,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Null(spec.TargetSiteIdentifier);
|
Assert.Null(spec.TargetSiteIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that repeated mapping specs accumulate into one list.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseSiteMappings_Repeated_AccumulatesAll()
|
public void ParseSiteMappings_Repeated_AccumulatesAll()
|
||||||
{
|
{
|
||||||
@@ -176,12 +194,14 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Null(specs[1].TargetSiteIdentifier);
|
Assert.Null(specs[1].TargetSiteIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a spec with an empty source throws a <see cref="FormatException"/>.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseSiteMappings_EmptySource_Throws()
|
public void ParseSiteMappings_EmptySource_Throws()
|
||||||
{
|
{
|
||||||
Assert.Throws<FormatException>(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" }));
|
Assert.Throws<FormatException>(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a site/name=target spec maps the source connection to an existing target connection.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting()
|
public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting()
|
||||||
{
|
{
|
||||||
@@ -192,6 +212,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Equal("OpcLive", spec.TargetConnectionName);
|
Assert.Equal("OpcLive", spec.TargetConnectionName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a connection spec with no target means the connection should be created new.</summary>
|
||||||
|
/// <param name="token">The raw connection mapping spec.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("NORTH-01/OpcA")] // no '=' part -> create-new
|
[InlineData("NORTH-01/OpcA")] // no '=' part -> create-new
|
||||||
[InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new
|
[InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new
|
||||||
@@ -204,6 +226,8 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Null(spec.TargetConnectionName);
|
Assert.Null(spec.TargetConnectionName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a connection spec with a malformed source throws a <see cref="FormatException"/>.</summary>
|
||||||
|
/// <param name="token">The malformed connection mapping spec.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("OpcA")] // missing site/name shape
|
[InlineData("OpcA")] // missing site/name shape
|
||||||
[InlineData("NORTH-01/")] // empty connection name
|
[InlineData("NORTH-01/")] // empty connection name
|
||||||
@@ -213,6 +237,7 @@ public class BundleCommandsStreamingTests : IDisposable
|
|||||||
Assert.Throws<FormatException>(() => BundleCommands.ParseConnectionMappings(new[] { token }));
|
Assert.Throws<FormatException>(() => BundleCommands.ParseConnectionMappings(new[] { token }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the import command accepts the map and create-missing flags without parse errors.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
|
public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class CachedCallCommandsTests
|
|||||||
|
|
||||||
// ── retry ──────────────────────────────────────────────────────────────
|
// ── retry ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that the retry command with a site id and message id produces the expected management command.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
||||||
{
|
{
|
||||||
@@ -37,6 +38,7 @@ public class CachedCallCommandsTests
|
|||||||
Assert.Equal("11111111-2222-3333-4444-555555555555", cmd.MessageId);
|
Assert.Equal("11111111-2222-3333-4444-555555555555", cmd.MessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the retry command requires both the site id and the tracked operation id.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Retry_SiteIdAndTrackedOperationId_AreRequired()
|
public void Retry_SiteIdAndTrackedOperationId_AreRequired()
|
||||||
{
|
{
|
||||||
@@ -50,6 +52,7 @@ public class CachedCallCommandsTests
|
|||||||
Assert.True(opId.Required, "--tracked-operation-id must be required.");
|
Assert.True(opId.Required, "--tracked-operation-id must be required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the retry command reports parse errors when its options are missing.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Retry_MissingOptions_ProducesParseErrors()
|
public void Retry_MissingOptions_ProducesParseErrors()
|
||||||
{
|
{
|
||||||
@@ -63,6 +66,7 @@ public class CachedCallCommandsTests
|
|||||||
|
|
||||||
// ── discard ────────────────────────────────────────────────────────────
|
// ── discard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that the discard command with a site id and message id produces the expected management command.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand()
|
||||||
{
|
{
|
||||||
@@ -79,6 +83,7 @@ public class CachedCallCommandsTests
|
|||||||
Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", cmd.MessageId);
|
Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", cmd.MessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the discard command requires both the site id and the tracked operation id.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Discard_SiteIdAndTrackedOperationId_AreRequired()
|
public void Discard_SiteIdAndTrackedOperationId_AreRequired()
|
||||||
{
|
{
|
||||||
@@ -92,6 +97,7 @@ public class CachedCallCommandsTests
|
|||||||
Assert.True(opId.Required, "--tracked-operation-id must be required.");
|
Assert.True(opId.Required, "--tracked-operation-id must be required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the discard command reports parse errors when its options are missing.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Discard_MissingOptions_ProducesParseErrors()
|
public void Discard_MissingOptions_ProducesParseErrors()
|
||||||
{
|
{
|
||||||
@@ -105,6 +111,7 @@ public class CachedCallCommandsTests
|
|||||||
|
|
||||||
// ── group shape ────────────────────────────────────────────────────────
|
// ── group shape ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that the cached-call command group contains the retry and discard subcommands.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CachedCallGroup_HasRetryAndDiscard()
|
public void CachedCallGroup_HasRetryAndDiscard()
|
||||||
{
|
{
|
||||||
@@ -115,6 +122,7 @@ public class CachedCallCommandsTests
|
|||||||
Assert.Contains("discard", subNames);
|
Assert.Contains("discard", subNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the cached-call command group is named cached-call.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CachedCallGroup_Name_IsCachedCall()
|
public void CachedCallGroup_Name_IsCachedCall()
|
||||||
{
|
{
|
||||||
@@ -124,6 +132,7 @@ public class CachedCallCommandsTests
|
|||||||
|
|
||||||
// ── registry round-trip ────────────────────────────────────────────────
|
// ── registry round-trip ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that the retry-parked-message command resolves through the command registry.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RetryParkedMessageCommand_ResolvesViaRegistry()
|
public void RetryParkedMessageCommand_ResolvesViaRegistry()
|
||||||
{
|
{
|
||||||
@@ -132,6 +141,7 @@ public class CachedCallCommandsTests
|
|||||||
Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name));
|
Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the discard-parked-message command resolves through the command registry.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DiscardParkedMessageCommand_ResolvesViaRegistry()
|
public void DiscardParkedMessageCommand_ResolvesViaRegistry()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public class ExternalSystemRetryOptionTests
|
|||||||
private static Option? FindOption(Command command, string name)
|
private static Option? FindOption(Command command, string name)
|
||||||
=> command.Options.SingleOrDefault(o => o.Name == name);
|
=> command.Options.SingleOrDefault(o => o.Name == name);
|
||||||
|
|
||||||
|
/// <summary>Verifies that the create and update subcommands expose the optional retry-policy options.</summary>
|
||||||
|
/// <param name="subcommand">The name of the subcommand under test.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("create")]
|
[InlineData("create")]
|
||||||
[InlineData("update")]
|
[InlineData("update")]
|
||||||
@@ -42,6 +44,7 @@ public class ExternalSystemRetryOptionTests
|
|||||||
Assert.False(retryDelay!.Required);
|
Assert.False(retryDelay!.Required);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the create subcommand parses the supplied retry-policy values.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_ParsesRetryPolicyValues()
|
public void Create_ParsesRetryPolicyValues()
|
||||||
{
|
{
|
||||||
@@ -56,6 +59,7 @@ public class ExternalSystemRetryOptionTests
|
|||||||
Assert.Equal(60, parse.GetValue<int?>("--retry-delay-seconds"));
|
Assert.Equal(60, parse.GetValue<int?>("--retry-delay-seconds"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the update subcommand parses the supplied retry-policy values.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Update_ParsesRetryPolicyValues()
|
public void Update_ParsesRetryPolicyValues()
|
||||||
{
|
{
|
||||||
@@ -70,6 +74,7 @@ public class ExternalSystemRetryOptionTests
|
|||||||
Assert.Equal(10, parse.GetValue<int?>("--retry-delay-seconds"));
|
Assert.Equal(10, parse.GetValue<int?>("--retry-delay-seconds"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the retry-delay option alias is accepted by the parser.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RetryDelayAliasIsAccepted()
|
public void RetryDelayAliasIsAccepted()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PLAN-06 Task 11: <c>external-system create</c> and <c>external-system update</c>
|
/// <c>external-system create</c> and <c>external-system update</c>
|
||||||
/// must expose an optional <c>--timeout-seconds</c> (<see cref="int"/>?) flag that
|
/// must expose an optional <c>--timeout-seconds</c> (<see cref="int"/>?) flag that
|
||||||
/// carries the per-system ESG HTTP timeout. Omitting it preserves the existing value
|
/// carries the per-system ESG HTTP timeout. Omitting it preserves the existing value
|
||||||
/// (update) / uses the gateway default (create).
|
/// (update) / uses the gateway default (create).
|
||||||
@@ -21,6 +21,7 @@ public class ExternalSystemTimeoutOptionTests
|
|||||||
private static Option? FindOption(Command command, string name)
|
private static Option? FindOption(Command command, string name)
|
||||||
=> command.Options.SingleOrDefault(o => o.Name == name);
|
=> command.Options.SingleOrDefault(o => o.Name == name);
|
||||||
|
|
||||||
|
/// <summary>Verifies that the create command exposes an optional timeout-seconds option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_HasOptionalTimeoutSecondsOption()
|
public void Create_HasOptionalTimeoutSecondsOption()
|
||||||
{
|
{
|
||||||
@@ -30,6 +31,7 @@ public class ExternalSystemTimeoutOptionTests
|
|||||||
Assert.False(option!.Required);
|
Assert.False(option!.Required);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the update command exposes an optional timeout-seconds option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Update_HasOptionalTimeoutSecondsOption()
|
public void Update_HasOptionalTimeoutSecondsOption()
|
||||||
{
|
{
|
||||||
@@ -39,6 +41,7 @@ public class ExternalSystemTimeoutOptionTests
|
|||||||
Assert.False(option!.Required);
|
Assert.False(option!.Required);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the create command parses a supplied timeout-seconds value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_ParsesTimeoutSecondsValue()
|
public void Create_ParsesTimeoutSecondsValue()
|
||||||
{
|
{
|
||||||
@@ -52,6 +55,7 @@ public class ExternalSystemTimeoutOptionTests
|
|||||||
Assert.Equal(15, parse.GetValue<int?>("--timeout-seconds"));
|
Assert.Equal(15, parse.GetValue<int?>("--timeout-seconds"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the update command parses a supplied timeout-seconds value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Update_ParsesTimeoutSecondsValue()
|
public void Update_ParsesTimeoutSecondsValue()
|
||||||
{
|
{
|
||||||
@@ -65,6 +69,7 @@ public class ExternalSystemTimeoutOptionTests
|
|||||||
Assert.Equal(20, parse.GetValue<int?>("--timeout-seconds"));
|
Assert.Equal(20, parse.GetValue<int?>("--timeout-seconds"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that omitting timeout-seconds on create parses without error.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_OmittedTimeoutSeconds_ParsesWithoutError()
|
public void Create_OmittedTimeoutSeconds_ParsesWithoutError()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class NotificationSmsCommandTests
|
|||||||
|
|
||||||
// ---- list create/update: --type + --phones ----------------------------
|
// ---- list create/update: --type + --phones ----------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that creating an SMS list with phones builds an SMS command carrying the recipient phones.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
||||||
{
|
{
|
||||||
@@ -55,6 +56,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Empty(cmd.RecipientEmails);
|
Assert.Empty(cmd.RecipientEmails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the list type option is matched case-insensitively.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListCreate_TypeIsCaseInsensitive()
|
public void ListCreate_TypeIsCaseInsensitive()
|
||||||
{
|
{
|
||||||
@@ -66,6 +68,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Equal(NotificationType.Sms, cmd.Type);
|
Assert.Equal(NotificationType.Sms, cmd.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a list created without a type defaults to email.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListCreate_DefaultType_IsEmail()
|
public void ListCreate_DefaultType_IsEmail()
|
||||||
{
|
{
|
||||||
@@ -79,6 +82,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Null(cmd.RecipientPhones);
|
Assert.Null(cmd.RecipientPhones);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an email list given phone numbers is rejected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListCreate_EmailTypeWithPhones_Throws()
|
public void ListCreate_EmailTypeWithPhones_Throws()
|
||||||
{
|
{
|
||||||
@@ -90,6 +94,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Contains("--phones", ex.Message);
|
Assert.Contains("--phones", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an SMS list with no phone numbers is rejected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListCreate_SmsTypeWithoutPhones_Throws()
|
public void ListCreate_SmsTypeWithoutPhones_Throws()
|
||||||
{
|
{
|
||||||
@@ -100,6 +105,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Contains("--phones", ex.Message);
|
Assert.Contains("--phones", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an email list with no email addresses is rejected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListCreate_EmailTypeWithoutEmails_Throws()
|
public void ListCreate_EmailTypeWithoutEmails_Throws()
|
||||||
{
|
{
|
||||||
@@ -110,6 +116,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Contains("--emails", ex.Message);
|
Assert.Contains("--emails", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an SMS list given email addresses is rejected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListCreate_SmsTypeWithEmails_Throws()
|
public void ListCreate_SmsTypeWithEmails_Throws()
|
||||||
{
|
{
|
||||||
@@ -121,6 +128,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Contains("--emails", ex.Message);
|
Assert.Contains("--emails", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unknown list type is rejected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListCreate_UnknownType_Throws()
|
public void ListCreate_UnknownType_Throws()
|
||||||
{
|
{
|
||||||
@@ -131,6 +139,7 @@ public class NotificationSmsCommandTests
|
|||||||
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that updating an SMS list with phones builds an SMS command carrying the recipient phones.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
||||||
{
|
{
|
||||||
@@ -146,6 +155,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Empty(cmd.RecipientEmails);
|
Assert.Empty(cmd.RecipientEmails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that updating an email list with phone numbers is rejected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ListUpdate_EmailTypeWithPhones_Throws()
|
public void ListUpdate_EmailTypeWithPhones_Throws()
|
||||||
{
|
{
|
||||||
@@ -160,6 +170,7 @@ public class NotificationSmsCommandTests
|
|||||||
|
|
||||||
// ---- notification sms group -------------------------------------------
|
// ---- notification sms group -------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that the notification command has an SMS group with list and update subcommands.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Notification_HasSmsGroupWithListAndUpdate()
|
public void Notification_HasSmsGroupWithListAndUpdate()
|
||||||
{
|
{
|
||||||
@@ -168,6 +179,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Contains("update", subNames);
|
Assert.Contains("update", subNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an SMS update with every flag produces a command carrying all of them.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem()
|
public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem()
|
||||||
{
|
{
|
||||||
@@ -187,6 +199,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Equal("new-secret", cmd.AuthToken);
|
Assert.Equal("new-secret", cmd.AuthToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that omitting the optional flags produces a command with null values for them.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls()
|
public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls()
|
||||||
{
|
{
|
||||||
@@ -204,6 +217,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Null(cmd.AuthToken);
|
Assert.Null(cmd.AuthToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the optional SMS update flags are not marked as required.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SmsUpdate_OptionalFlags_AreNotRequired()
|
public void SmsUpdate_OptionalFlags_AreNotRequired()
|
||||||
{
|
{
|
||||||
@@ -214,6 +228,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.False(update.Options.Single(o => o.Name == "--auth-token").Required);
|
Assert.False(update.Options.Single(o => o.Name == "--auth-token").Required);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an SMS update with only a messaging service SID and no from number builds successfully.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds()
|
public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds()
|
||||||
{
|
{
|
||||||
@@ -229,6 +244,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Equal("MGonly", cmd.MessagingServiceSid);
|
Assert.Equal("MGonly", cmd.MessagingServiceSid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an SMS update with neither a from number nor a messaging service SID is rejected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SmsUpdate_NeitherFromNorMessagingService_Throws()
|
public void SmsUpdate_NeitherFromNorMessagingService_Throws()
|
||||||
{
|
{
|
||||||
@@ -239,6 +255,7 @@ public class NotificationSmsCommandTests
|
|||||||
Assert.Throws<ArgumentException>(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse));
|
Assert.Throws<ArgumentException>(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that both SMS commands resolve through the management command registry.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SmsCommands_ResolveViaRegistry()
|
public void SmsCommands_ResolveViaRegistry()
|
||||||
{
|
{
|
||||||
@@ -262,6 +279,7 @@ public class NotificationSmsCommandTests
|
|||||||
[Collection("Console")]
|
[Collection("Console")]
|
||||||
public class NotificationSmsAuthTokenRenderingTests
|
public class NotificationSmsAuthTokenRenderingTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that a rendered SMS configuration list never contains the auth token value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken()
|
public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PLAN-07 Task 17 (arch-review P3): <c>secured-write list</c> must expose <c>--skip</c>
|
/// <c>secured-write list</c> must expose <c>--skip</c>
|
||||||
/// / <c>--take</c> offset-paging options alongside the <c>--status</c> / <c>--site</c>
|
/// / <c>--take</c> offset-paging options alongside the <c>--status</c> / <c>--site</c>
|
||||||
/// filters, and map them onto <see cref="Commons.Messages.Management.ListSecuredWritesCommand"/>.
|
/// filters, and map them onto <see cref="Commons.Messages.Management.ListSecuredWritesCommand"/>.
|
||||||
/// Omitting the paging flags preserves the historical unpaged default (skip 0, take 200).
|
/// Omitting the paging flags preserves the historical unpaged default (skip 0, take 200).
|
||||||
@@ -23,6 +23,7 @@ public class SecuredWriteListCommandTests
|
|||||||
private static Option? FindOption(Command command, string name)
|
private static Option? FindOption(Command command, string name)
|
||||||
=> command.Options.SingleOrDefault(o => o.Name == name);
|
=> command.Options.SingleOrDefault(o => o.Name == name);
|
||||||
|
|
||||||
|
/// <summary>Verifies that the list command exposes the skip and take options.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void List_HasSkipAndTakeOptions()
|
public void List_HasSkipAndTakeOptions()
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,7 @@ public class SecuredWriteListCommandTests
|
|||||||
Assert.NotNull(FindOption(list, "--take"));
|
Assert.NotNull(FindOption(list, "--take"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the list command parses skip, take and filters and forwards them to the command.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void List_ParsesSkipTakeAndFilters_ForwardsToCommand()
|
public void List_ParsesSkipTakeAndFilters_ForwardsToCommand()
|
||||||
{
|
{
|
||||||
@@ -47,6 +49,7 @@ public class SecuredWriteListCommandTests
|
|||||||
Assert.Equal(25, command.Take);
|
Assert.Equal(25, command.Take);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that omitting the paging options uses the unpaged defaults.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void List_OmittedPaging_UsesUnpagedDefaults()
|
public void List_OmittedPaging_UsesUnpagedDefaults()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class SmtpUpdateCommandTests
|
|||||||
private static ParseResult ParseUpdate(params string[] args)
|
private static ParseResult ParseUpdate(params string[] args)
|
||||||
=> SmtpUpdateCommand().Parse(args);
|
=> SmtpUpdateCommand().Parse(args);
|
||||||
|
|
||||||
|
/// <summary>Verifies that an update with a TLS mode and credentials produces a command carrying both.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem()
|
public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem()
|
||||||
{
|
{
|
||||||
@@ -49,6 +50,7 @@ public class SmtpUpdateCommandTests
|
|||||||
Assert.Equal("user:pass", cmd.Credentials);
|
Assert.Equal("user:pass", cmd.Credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an update without a TLS mode or credentials produces a command with null values for them.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls()
|
public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls()
|
||||||
{
|
{
|
||||||
@@ -64,6 +66,8 @@ public class SmtpUpdateCommandTests
|
|||||||
Assert.Null(cmd.Credentials);
|
Assert.Null(cmd.Credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the TLS mode option accepts each canonical value.</summary>
|
||||||
|
/// <param name="value">The TLS mode value supplied on the command line.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("None")]
|
[InlineData("None")]
|
||||||
[InlineData("StartTLS")]
|
[InlineData("StartTLS")]
|
||||||
@@ -78,6 +82,8 @@ public class SmtpUpdateCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the TLS mode option rejects values outside the canonical set.</summary>
|
||||||
|
/// <param name="value">The non-canonical TLS mode value supplied on the command line.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Bogus")]
|
[InlineData("Bogus")]
|
||||||
[InlineData("tls")]
|
[InlineData("tls")]
|
||||||
@@ -92,6 +98,7 @@ public class SmtpUpdateCommandTests
|
|||||||
Assert.NotEmpty(parse.Errors);
|
Assert.NotEmpty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an update with the EWS transport produces a command carrying that transport.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Update_WithTransportEws_ProducesCommandCarryingIt()
|
public void Update_WithTransportEws_ProducesCommandCarryingIt()
|
||||||
{
|
{
|
||||||
@@ -106,6 +113,7 @@ public class SmtpUpdateCommandTests
|
|||||||
Assert.Equal("Ews", cmd.Transport);
|
Assert.Equal("Ews", cmd.Transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an update without a transport produces a command with a null transport.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Update_WithoutTransport_ProducesCommandWithNullTransport()
|
public void Update_WithoutTransport_ProducesCommandWithNullTransport()
|
||||||
{
|
{
|
||||||
@@ -119,6 +127,8 @@ public class SmtpUpdateCommandTests
|
|||||||
Assert.Null(cmd.Transport);
|
Assert.Null(cmd.Transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the transport option rejects values outside the canonical set.</summary>
|
||||||
|
/// <param name="value">The non-canonical transport value supplied on the command line.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Graph")]
|
[InlineData("Graph")]
|
||||||
[InlineData("ews")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
|
[InlineData("ews")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
|
||||||
@@ -132,6 +142,7 @@ public class SmtpUpdateCommandTests
|
|||||||
Assert.NotEmpty(parse.Errors);
|
Assert.NotEmpty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the TLS mode and credentials options are not required.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Update_TlsModeAndCredentials_AreNotRequired()
|
public void Update_TlsModeAndCredentials_AreNotRequired()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MV-11: the <c>template attribute add</c> / <c>update</c> commands must support
|
/// The <c>template attribute add</c> / <c>update</c> commands must support
|
||||||
/// structured multi-value (List) attributes — a new <c>--element-type</c> option,
|
/// structured multi-value (List) attributes — a new <c>--element-type</c> option,
|
||||||
/// a JSON-array <c>--value</c>, client-side element-type validation, and
|
/// a JSON-array <c>--value</c>, client-side element-type validation, and
|
||||||
/// <see cref="AddTemplateAttributeCommand.ElementDataType"/> /
|
/// <see cref="AddTemplateAttributeCommand.ElementDataType"/> /
|
||||||
@@ -25,6 +25,7 @@ public class TemplateAttributeListTests
|
|||||||
|
|
||||||
// ---- option surface ----
|
// ---- option surface ----
|
||||||
|
|
||||||
|
/// <summary>Verifies that the attribute add command exposes an element-type option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AttributeAdd_HasElementTypeOption()
|
public void AttributeAdd_HasElementTypeOption()
|
||||||
{
|
{
|
||||||
@@ -32,6 +33,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.Contains("--element-type", add.Options.Select(o => o.Name));
|
Assert.Contains("--element-type", add.Options.Select(o => o.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the attribute update command exposes an element-type option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AttributeUpdate_HasElementTypeOption()
|
public void AttributeUpdate_HasElementTypeOption()
|
||||||
{
|
{
|
||||||
@@ -41,6 +43,8 @@ public class TemplateAttributeListTests
|
|||||||
|
|
||||||
// ---- client-side element-type validation (both directions) ----
|
// ---- client-side element-type validation (both directions) ----
|
||||||
|
|
||||||
|
/// <summary>Verifies that a List attribute with a valid scalar element type passes validation.</summary>
|
||||||
|
/// <param name="elementType">The scalar element type name under test.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("String")]
|
[InlineData("String")]
|
||||||
[InlineData("Int32")]
|
[InlineData("Int32")]
|
||||||
@@ -55,6 +59,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.Null(error);
|
Assert.Null(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that element-type validation for a List attribute is case-insensitive.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidateElementType_ListWithValidScalar_CaseInsensitive()
|
public void ValidateElementType_ListWithValidScalar_CaseInsensitive()
|
||||||
{
|
{
|
||||||
@@ -63,6 +68,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.Null(error);
|
Assert.Null(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a List attribute without an element type fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidateElementType_ListWithoutElementType_Error()
|
public void ValidateElementType_ListWithoutElementType_Error()
|
||||||
{
|
{
|
||||||
@@ -71,6 +77,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a List attribute with a blank element type fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidateElementType_ListWithBlankElementType_Error()
|
public void ValidateElementType_ListWithBlankElementType_Error()
|
||||||
{
|
{
|
||||||
@@ -79,6 +86,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a List attribute with an invalid scalar element type fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidateElementType_ListWithInvalidScalar_Error()
|
public void ValidateElementType_ListWithInvalidScalar_Error()
|
||||||
{
|
{
|
||||||
@@ -87,6 +95,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a List attribute with a binary element type fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidateElementType_ListWithBinaryScalar_Error()
|
public void ValidateElementType_ListWithBinaryScalar_Error()
|
||||||
{
|
{
|
||||||
@@ -96,6 +105,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a scalar attribute with an element type fails validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidateElementType_ScalarWithElementType_Error()
|
public void ValidateElementType_ScalarWithElementType_Error()
|
||||||
{
|
{
|
||||||
@@ -104,6 +114,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a scalar attribute without an element type passes validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidateElementType_ScalarWithoutElementType_Ok()
|
public void ValidateElementType_ScalarWithoutElementType_Ok()
|
||||||
{
|
{
|
||||||
@@ -114,6 +125,7 @@ public class TemplateAttributeListTests
|
|||||||
|
|
||||||
// ---- payload wiring: the raw JSON value + ElementDataType flow into the command ----
|
// ---- payload wiring: the raw JSON value + ElementDataType flow into the command ----
|
||||||
|
|
||||||
|
/// <summary>Verifies that building an add command for a List attribute carries the element type and the raw JSON value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
||||||
{
|
{
|
||||||
@@ -134,6 +146,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.Equal("""["WO-1","WO-2"]""", cmd.Value);
|
Assert.Equal("""["WO-1","WO-2"]""", cmd.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that building an update command for a List attribute carries the element type and the raw JSON value.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
||||||
{
|
{
|
||||||
@@ -153,6 +166,7 @@ public class TemplateAttributeListTests
|
|||||||
Assert.Equal("""["A","B"]""", cmd.Value);
|
Assert.Equal("""["A","B"]""", cmd.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that building an add command for a scalar attribute leaves the element type null.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull()
|
public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #54: <c>template script add/update</c> must expose <c>--min-time-between-runs</c>
|
/// <c>template script add/update</c> must expose <c>--min-time-between-runs</c>
|
||||||
/// (a duration with ms/s/min units) and <c>--execution-timeout-seconds</c> (an int)
|
/// (a duration with ms/s/min units) and <c>--execution-timeout-seconds</c> (an int)
|
||||||
/// so the per-script throttle/re-fire interval and the execution-timeout override —
|
/// so the per-script throttle/re-fire interval and the execution-timeout override —
|
||||||
/// previously settable only via Transport bundle import — are CLI-authorable. These
|
/// previously settable only via Transport bundle import — are CLI-authorable. These
|
||||||
@@ -24,6 +24,7 @@ public class TemplateScriptTimingTests
|
|||||||
|
|
||||||
// ---- option surface ----
|
// ---- option surface ----
|
||||||
|
|
||||||
|
/// <summary>Verifies that <c>template script add</c> exposes the timing options.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ScriptAdd_HasTimingOptions()
|
public void ScriptAdd_HasTimingOptions()
|
||||||
{
|
{
|
||||||
@@ -33,6 +34,7 @@ public class TemplateScriptTimingTests
|
|||||||
Assert.Contains("--execution-timeout-seconds", names);
|
Assert.Contains("--execution-timeout-seconds", names);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that <c>template script update</c> exposes the timing options.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ScriptUpdate_HasTimingOptions()
|
public void ScriptUpdate_HasTimingOptions()
|
||||||
{
|
{
|
||||||
@@ -44,6 +46,7 @@ public class TemplateScriptTimingTests
|
|||||||
|
|
||||||
// ---- --min-time-between-runs parsing ----
|
// ---- --min-time-between-runs parsing ----
|
||||||
|
|
||||||
|
/// <summary>Verifies that an absent minimum-time value parses as unset with no error.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MinTime_Absent_IsUnsetNoError()
|
public void MinTime_Absent_IsUnsetNoError()
|
||||||
{
|
{
|
||||||
@@ -52,6 +55,7 @@ public class TemplateScriptTimingTests
|
|||||||
Assert.Null(err);
|
Assert.Null(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a blank minimum-time value parses as unset with no error.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MinTime_Blank_IsUnsetNoError()
|
public void MinTime_Blank_IsUnsetNoError()
|
||||||
{
|
{
|
||||||
@@ -60,6 +64,9 @@ public class TemplateScriptTimingTests
|
|||||||
Assert.Null(err);
|
Assert.Null(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a millisecond duration string is parsed into the expected time span.</summary>
|
||||||
|
/// <param name="value">The duration string with a millisecond suffix.</param>
|
||||||
|
/// <param name="expectedMs">The expected number of milliseconds.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("500ms", 500)]
|
[InlineData("500ms", 500)]
|
||||||
[InlineData("250MS", 250)]
|
[InlineData("250MS", 250)]
|
||||||
@@ -70,6 +77,8 @@ public class TemplateScriptTimingTests
|
|||||||
Assert.Equal(TimeSpan.FromMilliseconds(expectedMs), d);
|
Assert.Equal(TimeSpan.FromMilliseconds(expectedMs), d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a seconds value, including a bare number, is parsed as seconds.</summary>
|
||||||
|
/// <param name="value">The duration string to parse.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("5")] // bare number → seconds
|
[InlineData("5")] // bare number → seconds
|
||||||
[InlineData("5s")]
|
[InlineData("5s")]
|
||||||
@@ -82,6 +91,8 @@ public class TemplateScriptTimingTests
|
|||||||
Assert.Equal(TimeSpan.FromSeconds(5), d);
|
Assert.Equal(TimeSpan.FromSeconds(5), d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a minutes duration string is parsed as minutes.</summary>
|
||||||
|
/// <param name="value">The duration string with a minutes suffix.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("2m")]
|
[InlineData("2m")]
|
||||||
[InlineData("2min")]
|
[InlineData("2min")]
|
||||||
@@ -93,6 +104,8 @@ public class TemplateScriptTimingTests
|
|||||||
Assert.Equal(TimeSpan.FromMinutes(2), d);
|
Assert.Equal(TimeSpan.FromMinutes(2), d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a zero duration is treated as unset, meaning no throttle.</summary>
|
||||||
|
/// <param name="value">A zero duration string.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("0")]
|
[InlineData("0")]
|
||||||
[InlineData("0s")]
|
[InlineData("0s")]
|
||||||
@@ -105,6 +118,8 @@ public class TemplateScriptTimingTests
|
|||||||
Assert.Null(d);
|
Assert.Null(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an invalid duration string is rejected with an error.</summary>
|
||||||
|
/// <param name="value">The invalid duration string.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("abc")]
|
[InlineData("abc")]
|
||||||
[InlineData("5h")] // hours not supported
|
[InlineData("5h")] // hours not supported
|
||||||
@@ -122,6 +137,7 @@ public class TemplateScriptTimingTests
|
|||||||
/// An absurdly large value (16-digit millisecond count) would overflow long/TimeSpan
|
/// An absurdly large value (16-digit millisecond count) would overflow long/TimeSpan
|
||||||
/// without the overflow guard. The parser must return false cleanly — not throw.
|
/// without the overflow guard. The parser must return false cleanly — not throw.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="value">An absurdly large duration string in milliseconds, seconds or minutes.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("9999999999999999ms")] // 16-digit ms: far exceeds TimeSpan.MaxValue (~922337203685477ms)
|
[InlineData("9999999999999999ms")] // 16-digit ms: far exceeds TimeSpan.MaxValue (~922337203685477ms)
|
||||||
[InlineData("9999999999999999s")] // 16-digit seconds: even larger when scaled
|
[InlineData("9999999999999999s")] // 16-digit seconds: even larger when scaled
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #257: <c>--trigger-kind</c> is written into the trigger-config JSON by
|
/// <c>--trigger-kind</c> is written into the trigger-config JSON by
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.CLI.TriggerConfigJson.InjectAnalysisKind"/>, which
|
/// <see cref="ZB.MOM.WW.ScadaBridge.CLI.TriggerConfigJson.InjectAnalysisKind"/>, which
|
||||||
/// returns null (dropping the kind) when no <c>--trigger-config</c> is supplied. Passing
|
/// returns null (dropping the kind) when no <c>--trigger-config</c> is supplied. Passing
|
||||||
/// <c>--trigger-kind</c> without <c>--trigger-config</c> was therefore a silent no-op.
|
/// <c>--trigger-kind</c> without <c>--trigger-config</c> was therefore a silent no-op.
|
||||||
@@ -12,18 +12,21 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class TemplateTriggerKindIgnoredTests
|
public class TemplateTriggerKindIgnoredTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that a trigger kind supplied without a trigger config is reported as ignored.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void KindWithoutConfig_WillBeIgnored()
|
public void KindWithoutConfig_WillBeIgnored()
|
||||||
{
|
{
|
||||||
Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: "strict"));
|
Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: "strict"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a trigger kind supplied with a blank trigger config is reported as ignored.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void KindWithBlankConfig_WillBeIgnored()
|
public void KindWithBlankConfig_WillBeIgnored()
|
||||||
{
|
{
|
||||||
Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: " ", triggerKind: "strict"));
|
Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: " ", triggerKind: "strict"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a trigger kind supplied with a trigger config is not reported as ignored.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void KindWithConfig_NotIgnored()
|
public void KindWithConfig_NotIgnored()
|
||||||
{
|
{
|
||||||
@@ -31,6 +34,7 @@ public class TemplateTriggerKindIgnoredTests
|
|||||||
triggerConfig: "{\"expression\":\"x > 0\"}", triggerKind: "strict"));
|
triggerConfig: "{\"expression\":\"x > 0\"}", triggerKind: "strict"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that omitting the trigger kind is not reported as ignored.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NoKind_NotIgnored()
|
public void NoKind_NotIgnored()
|
||||||
{
|
{
|
||||||
@@ -39,6 +43,7 @@ public class TemplateTriggerKindIgnoredTests
|
|||||||
Assert.False(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: " "));
|
Assert.False(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: " "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a trigger config supplied without a trigger kind is not reported as ignored.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NoKindWithConfig_NotIgnored()
|
public void NoKindWithConfig_NotIgnored()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// M9-T28b: <c>template alarm add/update</c> and <c>template script add/update</c>
|
/// <c>template alarm add/update</c> and <c>template script add/update</c>
|
||||||
/// must expose a <c>--trigger-kind</c> option (advisory|strict) that writes
|
/// must expose a <c>--trigger-kind</c> option (advisory|strict) that writes
|
||||||
/// <c>"analysisKind":"Strict"</c> into the trigger-config JSON for expression
|
/// <c>"analysisKind":"Strict"</c> into the trigger-config JSON for expression
|
||||||
/// triggers, matching the T28a backend contract exactly.
|
/// triggers, matching the T28a backend contract exactly.
|
||||||
@@ -17,6 +17,7 @@ public class TemplateTriggerKindTests
|
|||||||
// alarm-update / script-add / script-update ──────────────────────────────
|
// alarm-update / script-add / script-update ──────────────────────────────
|
||||||
|
|
||||||
// Inject strict into an Expression config that has no prior analysisKind
|
// Inject strict into an Expression config that has no prior analysisKind
|
||||||
|
/// <summary>Verifies that injecting the Strict analysis kind adds the analysis kind key to the trigger config.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InjectAnalysisKind_Strict_AddsKey()
|
public void InjectAnalysisKind_Strict_AddsKey()
|
||||||
{
|
{
|
||||||
@@ -29,6 +30,7 @@ public class TemplateTriggerKindTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inject advisory (null) into config that had Strict → key removed
|
// Inject advisory (null) into config that had Strict → key removed
|
||||||
|
/// <summary>Verifies that injecting the Advisory analysis kind removes the analysis kind key from the trigger config.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InjectAnalysisKind_Advisory_RemovesKey()
|
public void InjectAnalysisKind_Advisory_RemovesKey()
|
||||||
{
|
{
|
||||||
@@ -41,6 +43,7 @@ public class TemplateTriggerKindTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inject strict into config that already had Strict → still Strict (idempotent)
|
// Inject strict into config that already had Strict → still Strict (idempotent)
|
||||||
|
/// <summary>Verifies that injecting the Strict analysis kind twice yields the same config as injecting it once.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InjectAnalysisKind_Strict_Idempotent()
|
public void InjectAnalysisKind_Strict_Idempotent()
|
||||||
{
|
{
|
||||||
@@ -53,6 +56,7 @@ public class TemplateTriggerKindTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Null config → returns null (no config to inject into)
|
// Null config → returns null (no config to inject into)
|
||||||
|
/// <summary>Verifies that injecting an analysis kind into a null config returns null.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InjectAnalysisKind_NullConfig_ReturnsNull()
|
public void InjectAnalysisKind_NullConfig_ReturnsNull()
|
||||||
{
|
{
|
||||||
@@ -61,6 +65,7 @@ public class TemplateTriggerKindTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Case-insensitive: "STRICT" → canonical "Strict"
|
// Case-insensitive: "STRICT" → canonical "Strict"
|
||||||
|
/// <summary>Verifies that the Strict analysis kind is recognized regardless of letter casing.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InjectAnalysisKind_Strict_CaseInsensitive()
|
public void InjectAnalysisKind_Strict_CaseInsensitive()
|
||||||
{
|
{
|
||||||
@@ -73,6 +78,7 @@ public class TemplateTriggerKindTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Other keys preserved when injecting
|
// Other keys preserved when injecting
|
||||||
|
/// <summary>Verifies that injecting an analysis kind leaves the other trigger config keys unchanged.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InjectAnalysisKind_OtherKeysPreserved()
|
public void InjectAnalysisKind_OtherKeysPreserved()
|
||||||
{
|
{
|
||||||
@@ -88,6 +94,7 @@ public class TemplateTriggerKindTests
|
|||||||
|
|
||||||
// ── Option surface: alarm add + update have --trigger-kind ───────────────
|
// ── Option surface: alarm add + update have --trigger-kind ───────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that the alarm add command exposes a trigger-kind option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmAdd_HasTriggerKindOption()
|
public void AlarmAdd_HasTriggerKindOption()
|
||||||
{
|
{
|
||||||
@@ -95,6 +102,7 @@ public class TemplateTriggerKindTests
|
|||||||
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
|
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the alarm update command exposes a trigger-kind option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmUpdate_HasTriggerKindOption()
|
public void AlarmUpdate_HasTriggerKindOption()
|
||||||
{
|
{
|
||||||
@@ -104,6 +112,7 @@ public class TemplateTriggerKindTests
|
|||||||
|
|
||||||
// ── Option surface: script add + update have --trigger-kind ─────────────
|
// ── Option surface: script add + update have --trigger-kind ─────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that the script add command exposes a trigger-kind option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ScriptAdd_HasTriggerKindOption()
|
public void ScriptAdd_HasTriggerKindOption()
|
||||||
{
|
{
|
||||||
@@ -111,6 +120,7 @@ public class TemplateTriggerKindTests
|
|||||||
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
|
Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the script update command exposes a trigger-kind option.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ScriptUpdate_HasTriggerKindOption()
|
public void ScriptUpdate_HasTriggerKindOption()
|
||||||
{
|
{
|
||||||
@@ -120,6 +130,7 @@ public class TemplateTriggerKindTests
|
|||||||
|
|
||||||
// ── AlarmTriggerConfigJson.Build — analysisKind injection ────────────────
|
// ── AlarmTriggerConfigJson.Build — analysisKind injection ────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies that building an expression alarm config with the Strict kind injects an analysis kind of Strict.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmConfigBuild_Expression_StrictKind_InjectsAnalysisKindStrict()
|
public void AlarmConfigBuild_Expression_StrictKind_InjectsAnalysisKindStrict()
|
||||||
{
|
{
|
||||||
@@ -139,6 +150,7 @@ public class TemplateTriggerKindTests
|
|||||||
Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString());
|
Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that building an expression alarm config with the Advisory kind omits the analysis kind key.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmConfigBuild_Expression_AdvisoryKind_OmitsAnalysisKindKey()
|
public void AlarmConfigBuild_Expression_AdvisoryKind_OmitsAnalysisKindKey()
|
||||||
{
|
{
|
||||||
@@ -158,6 +170,7 @@ public class TemplateTriggerKindTests
|
|||||||
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that building an expression alarm config with no kind omits the analysis kind key.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmConfigBuild_Expression_NullKind_OmitsAnalysisKindKey()
|
public void AlarmConfigBuild_Expression_NullKind_OmitsAnalysisKindKey()
|
||||||
{
|
{
|
||||||
@@ -177,6 +190,7 @@ public class TemplateTriggerKindTests
|
|||||||
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a non-expression alarm config does not emit an analysis kind even when the Strict kind is given.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmConfigBuild_NonExpression_StrictKind_DoesNotEmitAnalysisKind()
|
public void AlarmConfigBuild_NonExpression_StrictKind_DoesNotEmitAnalysisKind()
|
||||||
{
|
{
|
||||||
@@ -196,6 +210,7 @@ public class TemplateTriggerKindTests
|
|||||||
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the Strict kind is recognized case-insensitively when building an expression alarm config.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmConfigBuild_Expression_StrictKind_CaseInsensitive()
|
public void AlarmConfigBuild_Expression_StrictKind_CaseInsensitive()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ public class ConnectionBrowseCommandsTests
|
|||||||
return (root, captured);
|
return (root, captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the browse command builds a browse-node command addressed by site identifier.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier()
|
public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier()
|
||||||
{
|
{
|
||||||
@@ -61,6 +63,8 @@ public class ConnectionBrowseCommandsTests
|
|||||||
Assert.Equal("ns=2;s=X", cmd.ParentNodeId);
|
Assert.Equal("ns=2;s=X", cmd.ParentNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that browsing without a node id starts from the address-space root.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Browse_WithoutNodeId_BrowsesFromRoot()
|
public async Task Browse_WithoutNodeId_BrowsesFromRoot()
|
||||||
{
|
{
|
||||||
@@ -76,6 +80,8 @@ public class ConnectionBrowseCommandsTests
|
|||||||
Assert.Null(cmd.ParentNodeId);
|
Assert.Null(cmd.ParentNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the search command builds a search-address-space command addressed by site identifier.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier()
|
public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier()
|
||||||
{
|
{
|
||||||
@@ -98,6 +104,8 @@ public class ConnectionBrowseCommandsTests
|
|||||||
Assert.Equal(50, cmd.MaxResults);
|
Assert.Equal(50, cmd.MaxResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that verify-endpoint reads the configuration JSON from a file and addresses the site by identifier.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task VerifyEndpoint_ReadsConfigJsonFromFile_WithSiteIdentifier()
|
public async Task VerifyEndpoint_ReadsConfigJsonFromFile_WithSiteIdentifier()
|
||||||
{
|
{
|
||||||
@@ -128,6 +136,8 @@ public class ConnectionBrowseCommandsTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that certs list builds a list-server-certs command addressed by site identifier.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier()
|
public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier()
|
||||||
{
|
{
|
||||||
@@ -142,6 +152,8 @@ public class ConnectionBrowseCommandsTests
|
|||||||
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that certs trust base64-encodes the DER certificate file and addresses the site by identifier.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CertsTrust_Base64EncodesDerFile_WithSiteIdentifier()
|
public async Task CertsTrust_Base64EncodesDerFile_WithSiteIdentifier()
|
||||||
{
|
{
|
||||||
@@ -173,6 +185,8 @@ public class ConnectionBrowseCommandsTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that certs remove builds a remove-server-cert command addressed by site identifier.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier()
|
public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Regression tests for CLI-006 — credentials could only be supplied via the
|
/// Regression tests for environment-based credential supply — credentials could only be supplied via the
|
||||||
/// <c>--password</c> command-line option, which leaks into process listings and
|
/// <c>--password</c> command-line option, which leaks into process listings and
|
||||||
/// shell history. A <c>SCADABRIDGE_PASSWORD</c> / <c>SCADABRIDGE_USERNAME</c> environment
|
/// shell history. A <c>SCADABRIDGE_PASSWORD</c> / <c>SCADABRIDGE_USERNAME</c> environment
|
||||||
/// fallback gives CI/CD a safer alternative.
|
/// fallback gives CI/CD a safer alternative.
|
||||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
|||||||
[Collection("Environment")]
|
[Collection("Environment")]
|
||||||
public class CredentialResolutionTests
|
public class CredentialResolutionTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the password is loaded from the environment variable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_Password_FromEnvironment()
|
public void Load_Password_FromEnvironment()
|
||||||
{
|
{
|
||||||
@@ -29,6 +30,7 @@ public class CredentialResolutionTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the username is loaded from the environment variable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_Username_FromEnvironment()
|
public void Load_Username_FromEnvironment()
|
||||||
{
|
{
|
||||||
@@ -47,6 +49,7 @@ public class CredentialResolutionTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that loading with no credential environment variables leaves the credentials null.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_NoCredentialEnvVars_LeavesCredentialsNull()
|
public void Load_NoCredentialEnvVars_LeavesCredentialsNull()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Regression tests for the testable pieces of <c>DebugCommands.StreamDebugAsync</c>:
|
/// Regression tests for the testable pieces of <c>DebugCommands.StreamDebugAsync</c>:
|
||||||
/// CLI-010 (Ctrl+C during connect misreported as a connection failure) and
|
/// Ctrl+C during connect being misreported as a connection failure, and
|
||||||
/// CLI-012 (non-deterministic exit code after stream termination).
|
/// non-deterministic exit codes after stream termination.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DebugStreamTests
|
public class DebugStreamTests
|
||||||
{
|
{
|
||||||
// --- CLI-010: connect-failure classification --------------------------------------
|
// --- connect-failure classification --------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that an <see cref="OperationCanceledException"/> raised after cancellation was requested is treated as a graceful cancellation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation()
|
public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation()
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,7 @@ public class DebugStreamTests
|
|||||||
Assert.Equal(0, result.ExitCode);
|
Assert.Equal(0, result.ExitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a <see cref="TaskCanceledException"/> raised after cancellation was requested is treated as a cancellation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation()
|
public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation()
|
||||||
{
|
{
|
||||||
@@ -34,6 +36,7 @@ public class DebugStreamTests
|
|||||||
Assert.Equal(0, result.ExitCode);
|
Assert.Equal(0, result.ExitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a genuine connection exception is classified as a connection failure with exit code 1.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClassifyConnectFailure_RealException_IsConnectionFailure()
|
public void ClassifyConnectFailure_RealException_IsConnectionFailure()
|
||||||
{
|
{
|
||||||
@@ -44,6 +47,7 @@ public class DebugStreamTests
|
|||||||
Assert.Equal(1, result.ExitCode);
|
Assert.Equal(1, result.ExitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a cancellation exception without a user cancel request is classified as a connection failure.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure()
|
public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure()
|
||||||
{
|
{
|
||||||
@@ -56,8 +60,10 @@ public class DebugStreamTests
|
|||||||
Assert.Equal(1, result.ExitCode);
|
Assert.Equal(1, result.ExitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CLI-012: deterministic exit-code resolution ----------------------------------
|
// --- deterministic exit-code resolution ----------------------------------
|
||||||
|
|
||||||
|
/// <summary>Verifies that a result already set by stream termination is preferred when resolving the exit code.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult()
|
public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult()
|
||||||
{
|
{
|
||||||
@@ -70,6 +76,8 @@ public class DebugStreamTests
|
|||||||
Assert.Equal(1, code);
|
Assert.Equal(1, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the exit code resolves to zero when no termination result was ever set.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero()
|
public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero()
|
||||||
{
|
{
|
||||||
@@ -81,6 +89,8 @@ public class DebugStreamTests
|
|||||||
Assert.Equal(0, code);
|
Assert.Equal(0, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a result arriving during the grace period is observed when resolving the exit code.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved()
|
public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ public class DeployArtifactsExitCodeTests
|
|||||||
"{\"siteId\":\"site-b\",\"siteName\":\"B\",\"success\":false,\"errorMessage\":\"Secret 'k' ... missing\"}]," +
|
"{\"siteId\":\"site-b\",\"siteName\":\"B\",\"success\":false,\"errorMessage\":\"Secret 'k' ... missing\"}]," +
|
||||||
"\"successCount\":1,\"failureCount\":1}";
|
"\"successCount\":1,\"failureCount\":1}";
|
||||||
|
|
||||||
|
/// <summary>Verifies that the artifact deploy command exits with a non-zero code exactly when a site failed or the response is unrecognized.</summary>
|
||||||
|
/// <param name="group">The deploy command group under test (<c>site</c> or <c>deploy</c>).</param>
|
||||||
|
/// <param name="response">The JSON response the management endpoint returns.</param>
|
||||||
|
/// <param name="expected">The expected process exit code.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("site", AllSucceeded, 0)]
|
[InlineData("site", AllSucceeded, 0)]
|
||||||
[InlineData("site", OneFailed, 1)]
|
[InlineData("site", OneFailed, 1)]
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
|||||||
|
|
||||||
public class DeploySiteExitCodeTests
|
public class DeploySiteExitCodeTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that the actual deploy command returns an exit code derived from the success and failure counts in the response.</summary>
|
||||||
|
/// <param name="response">The JSON response body served by the fake management endpoint.</param>
|
||||||
|
/// <param name="expected">The expected process exit code.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("{\"successCount\":2,\"failureCount\":0}", 0)]
|
[InlineData("{\"successCount\":2,\"failureCount\":0}", 0)]
|
||||||
[InlineData("{\"successCount\":1,\"failureCount\":1}", 1)]
|
[InlineData("{\"successCount\":1,\"failureCount\":1}", 1)]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
|||||||
[Collection("Console")]
|
[Collection("Console")]
|
||||||
public class DeploymentDeadlineParityTests
|
public class DeploymentDeadlineParityTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Verifies that every single-deployment CLI alias waits for a server reply beyond the ordinary 30-second deadline.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline()
|
public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public class DeploymentStatusTableProjectionTests
|
|||||||
PageSize: rowCount),
|
PageSize: rowCount),
|
||||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
|
|
||||||
|
/// <summary>Verifies that projection: unwraps the envelope to a row array.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Projection_UnwrapsTheEnvelopeToARowArray()
|
public void Projection_UnwrapsTheEnvelopeToARowArray()
|
||||||
{
|
{
|
||||||
@@ -47,6 +48,7 @@ public class DeploymentStatusTableProjectionTests
|
|||||||
Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString());
|
Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that projection, when page is a strict subset, appends a total note.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote()
|
public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote()
|
||||||
{
|
{
|
||||||
@@ -59,6 +61,7 @@ public class DeploymentStatusTableProjectionTests
|
|||||||
Assert.Contains("--page-size", note);
|
Assert.Contains("--page-size", note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that projection, when page is the whole result, emits no note column.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn()
|
public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn()
|
||||||
{
|
{
|
||||||
@@ -69,6 +72,7 @@ public class DeploymentStatusTableProjectionTests
|
|||||||
e => Assert.False(e.TryGetProperty("note", out _)));
|
e => Assert.False(e.TryGetProperty("note", out _)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that projection, when accepts a bare row array, from a pre-envelope server.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer()
|
public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer()
|
||||||
{
|
{
|
||||||
@@ -79,6 +83,8 @@ public class DeploymentStatusTableProjectionTests
|
|||||||
.GetProperty("deploymentId").GetString());
|
.GetProperty("deploymentId").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that projection: leaves unrecognised bodies untouched.</summary>
|
||||||
|
/// <param name="body">The body.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("not json at all")]
|
[InlineData("not json at all")]
|
||||||
[InlineData("""{"error":"boom"}""")]
|
[InlineData("""{"error":"boom"}""")]
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ public class EditingParityTests
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that each command sends the exact management envelope with the expected command name and payload.</summary>
|
||||||
|
/// <param name="arguments">The command-line arguments to run.</param>
|
||||||
|
/// <param name="command">The expected management command name.</param>
|
||||||
|
/// <param name="payload">The expected JSON payload.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")]
|
[InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")]
|
||||||
[InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")]
|
[InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")]
|
||||||
@@ -74,6 +79,8 @@ public class EditingParityTests
|
|||||||
finally { listener.Stop(); }
|
finally { listener.Stop(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that missing or malformed arguments are rejected.</summary>
|
||||||
|
/// <param name="arguments">The invalid command-line arguments to run.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("template resolved-members")]
|
[InlineData("template resolved-members")]
|
||||||
[InlineData("template move --id 12 --folder-id invalid")]
|
[InlineData("template move --id 12 --folder-id invalid")]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Regression tests for CLI-009 — the design doc defines exit code 2 as "authorization
|
/// Regression tests for the exit-code contract: the design doc defines exit code 2 as "authorization
|
||||||
/// failure". The previous implementation keyed exit 2 solely off HTTP 403, so an
|
/// failure". The previous implementation keyed exit 2 solely off HTTP 403, so an
|
||||||
/// authorization failure the server signalled with an error <c>code</c> of
|
/// authorization failure the server signalled with an error <c>code</c> of
|
||||||
/// <c>UNAUTHORIZED</c>/<c>FORBIDDEN</c> but a different HTTP status was misreported as a
|
/// <c>UNAUTHORIZED</c>/<c>FORBIDDEN</c> but a different HTTP status was misreported as a
|
||||||
@@ -27,12 +27,15 @@ public class ExitCodeTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_Http403_ReturnsTwo()
|
public void HandleResponse_Http403_ReturnsTwo()
|
||||||
{
|
{
|
||||||
Assert.Equal(2, HandleQuietly(new ManagementResponse(403, null, "Forbidden", "FORBIDDEN")));
|
Assert.Equal(2, HandleQuietly(new ManagementResponse(403, null, "Forbidden", "FORBIDDEN")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an authorization error code with a status other than 403 still returns exit code 2.</summary>
|
||||||
|
/// <param name="code">The error code returned by the server.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("UNAUTHORIZED")]
|
[InlineData("UNAUTHORIZED")]
|
||||||
[InlineData("FORBIDDEN")]
|
[InlineData("FORBIDDEN")]
|
||||||
@@ -40,16 +43,18 @@ public class ExitCodeTests
|
|||||||
public void HandleResponse_AuthorizationCode_NonForbiddenStatus_ReturnsTwo(string code)
|
public void HandleResponse_AuthorizationCode_NonForbiddenStatus_ReturnsTwo(string code)
|
||||||
{
|
{
|
||||||
// The server signalled an authorization failure via the error code but with a
|
// The server signalled an authorization failure via the error code but with a
|
||||||
// non-403 HTTP status; per the documented exit-code table this is still exit 2.
|
// HTTP status other than 403 status; per the documented exit-code table this is still exit 2.
|
||||||
Assert.Equal(2, HandleQuietly(new ManagementResponse(400, null, "Access denied", code)));
|
Assert.Equal(2, HandleQuietly(new ManagementResponse(400, null, "Access denied", code)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a generic error returns exit code 1.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_GenericError_ReturnsOne()
|
public void HandleResponse_GenericError_ReturnsOne()
|
||||||
{
|
{
|
||||||
Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT")));
|
Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an authentication failure returns exit code 1.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleResponse_AuthenticationFailure_ReturnsOne()
|
public void HandleResponse_AuthenticationFailure_ReturnsOne()
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user