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:
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle B (M2-T4) tests for <see cref="FallbackAuditWriter"/> — composes the
|
||||
/// Tests for <see cref="FallbackAuditWriter"/> — composes the
|
||||
/// primary <see cref="SqliteAuditWriter"/>, the drop-oldest
|
||||
/// <see cref="RingBufferFallback"/>, and an
|
||||
/// <see cref="IAuditWriteFailureCounter"/> health counter.
|
||||
@@ -28,9 +28,12 @@ public class FallbackAuditWriterTests
|
||||
/// <summary>Flip-switch primary writer mock.</summary>
|
||||
private sealed class FlipSwitchPrimary : IAuditWriter
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether the next write fails.</summary>
|
||||
public bool FailNext { get; set; }
|
||||
/// <summary>Gets the events the primary has accepted.</summary>
|
||||
public List<AuditEvent> Written { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (FailNext)
|
||||
@@ -42,6 +45,8 @@ public class FallbackAuditWriterTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when the primary writer throws, the event lands in the ring buffer and the call still returns successfully.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess()
|
||||
{
|
||||
@@ -59,6 +64,8 @@ public class FallbackAuditWriterTests
|
||||
counter.Received(1).Increment();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that after the primary recovers, the ring buffer drains in FIFO order on the next write.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite()
|
||||
{
|
||||
@@ -93,6 +100,8 @@ public class FallbackAuditWriterTests
|
||||
Assert.Equal("c", primary.Written[3].Target);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the ring buffer stays empty when the primary writer always succeeds.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty()
|
||||
{
|
||||
@@ -112,6 +121,8 @@ public class FallbackAuditWriterTests
|
||||
counter.DidNotReceive().Increment();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the failure counter is incremented once per primary failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure()
|
||||
{
|
||||
|
||||
+5
-2
@@ -5,17 +5,18 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle C (M5-T7) — the <see cref="HealthMetricsAuditRedactionFailureCounter"/>
|
||||
/// The <see cref="HealthMetricsAuditRedactionFailureCounter"/>
|
||||
/// adapter is the production binding for
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/> on
|
||||
/// site nodes; it forwards every <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
|
||||
/// redactor over-redaction event into the shared
|
||||
/// <see cref="ISiteHealthCollector"/> so the site health report surfaces the
|
||||
/// count as <c>AuditRedactionFailure</c>. Mirrors the M2 Bundle G
|
||||
/// count as <c>AuditRedactionFailure</c>. Mirrors the
|
||||
/// HealthMetricsAuditWriteFailureCounter shape one-for-one.
|
||||
/// </summary>
|
||||
public class HealthMetricsAuditRedactionFailureCounterTests
|
||||
{
|
||||
/// <summary>Verifies the case: increment, routes, to, collector, increment Audit Redaction Failure.</summary>
|
||||
[Fact]
|
||||
public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure()
|
||||
{
|
||||
@@ -27,6 +28,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests
|
||||
collector.Received(1).IncrementAuditRedactionFailure();
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: increment, multiple, calls, route, to, collector, each, time.</summary>
|
||||
[Fact]
|
||||
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests
|
||||
collector.Received(3).IncrementAuditRedactionFailure();
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: construction, with, null, collector, throws, argument Null Exception.</summary>
|
||||
[Fact]
|
||||
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
|
||||
{
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle G (M2-T11) — the <see cref="HealthMetricsAuditWriteFailureCounter"/>
|
||||
/// The <see cref="HealthMetricsAuditWriteFailureCounter"/>
|
||||
/// adapter is the production binding for <see cref="IAuditWriteFailureCounter"/>
|
||||
/// on site nodes; it forwards every FallbackAuditWriter primary failure into
|
||||
/// the shared <see cref="ISiteHealthCollector"/> so the site health report
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
public class HealthMetricsAuditWriteFailureCounterTests
|
||||
{
|
||||
/// <summary>Verifies that a single increment is routed to the collector as a site audit write failure.</summary>
|
||||
[Fact]
|
||||
public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public class HealthMetricsAuditWriteFailureCounterTests
|
||||
collector.Received(1).IncrementSiteAuditWriteFailures();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that each of several increments is routed to the collector.</summary>
|
||||
[Fact]
|
||||
public void Increment_Multiple_Calls_Route_To_Collector_Each_Time()
|
||||
{
|
||||
@@ -37,6 +39,7 @@ public class HealthMetricsAuditWriteFailureCounterTests
|
||||
collector.Received(3).IncrementSiteAuditWriteFailures();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that constructing the counter with a null collector throws an <see cref="ArgumentNullException"/>.</summary>
|
||||
[Fact]
|
||||
public void Construction_With_Null_Collector_Throws_ArgumentNullException()
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle B (M2-T3) tests for <see cref="RingBufferFallback"/> — the
|
||||
/// Tests for <see cref="RingBufferFallback"/> — the
|
||||
/// drop-oldest fallback used by <see cref="FallbackAuditWriter"/> when the
|
||||
/// primary SQLite writer is throwing.
|
||||
/// </summary>
|
||||
@@ -23,6 +23,8 @@ public class RingBufferFallbackTests
|
||||
target: target);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that enqueuing 1025 events into a ring with a capacity of 1024 drops the oldest event and raises the overflow notification exactly once.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce()
|
||||
{
|
||||
@@ -51,6 +53,8 @@ public class RingBufferFallbackTests
|
||||
Assert.Equal("1024", drained[^1].Target);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that draining yields the queued events in FIFO order and then completes once the ring is empty.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty()
|
||||
{
|
||||
@@ -76,6 +80,7 @@ public class RingBufferFallbackTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that enqueuing a single event into a ring with spare capacity succeeds and returns true.</summary>
|
||||
[Fact]
|
||||
public void TryEnqueue_AllSucceeds_ReturnsTrue()
|
||||
{
|
||||
|
||||
+27
-2
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Deferred-work #21: the backlog reporter's poll cadence is configurable via
|
||||
/// The backlog reporter's poll cadence is configurable via
|
||||
/// <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/> instead of the
|
||||
/// old hard-coded 30 s constant.
|
||||
/// </summary>
|
||||
@@ -26,6 +26,8 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
explicitInterval,
|
||||
options);
|
||||
|
||||
/// <summary>Verifies that stopping the reporter while a probe is in flight lets the loop complete cleanly.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StopAsync_WhileProbeInFlight_LoopCompletesCleanly()
|
||||
{
|
||||
@@ -62,10 +64,12 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
throw new UnreachableException("the delay above always throws on cancellation");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stopping the reporter after it has been disposed does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
||||
{
|
||||
// Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync
|
||||
// Regression (follow-up): Dispose tears down the CTS StopAsync
|
||||
// cancels, and the host does not guarantee StopAsync is driven before the DI
|
||||
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
||||
// escape an IHostedService aborts the host's whole shutdown sequence.
|
||||
@@ -79,6 +83,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
reporter.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the poll cadence comes from the options when one is configured.</summary>
|
||||
[Fact]
|
||||
public void Cadence_ComesFromOptions_WhenConfigured()
|
||||
{
|
||||
@@ -89,6 +94,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-positive configured interval falls back to the default cadence.</summary>
|
||||
[Fact]
|
||||
public void Cadence_FallsBackToDefault_WhenOptionsNonPositive()
|
||||
{
|
||||
@@ -99,6 +105,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default cadence is used when no options are supplied.</summary>
|
||||
[Fact]
|
||||
public void Cadence_FallsBackToDefault_WhenNoOptions()
|
||||
{
|
||||
@@ -107,6 +114,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an explicitly supplied interval takes precedence over the options value.</summary>
|
||||
[Fact]
|
||||
public void ExplicitInterval_WinsOverOptions()
|
||||
{
|
||||
@@ -119,6 +127,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
|
||||
// ----- Stale-Pending signal (review F5) ----- //
|
||||
|
||||
/// <summary>Verifies that a stale pending backlog is logged as a warning and that repeat warnings are then rate-limited.</summary>
|
||||
[Fact]
|
||||
public void StalePendingBacklog_IsWarned_ThenRateLimited()
|
||||
{
|
||||
@@ -143,6 +152,7 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
Assert.Contains("pending", warning.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a fresh or empty pending backlog does not log a warning.</summary>
|
||||
[Fact]
|
||||
public void FreshOrEmptyPendingBacklog_IsNotWarned()
|
||||
{
|
||||
@@ -163,12 +173,27 @@ public class SiteAuditBacklogReporterCadenceTests
|
||||
/// <summary>Captures log entries so the stale-pending signal can be asserted.</summary>
|
||||
private sealed class CapturingLogger : ILogger<SiteAuditBacklogReporter>
|
||||
{
|
||||
/// <summary>Gets the log entries captured so far.</summary>
|
||||
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
||||
|
||||
/// <summary>Returns no scope; the fake logger does not track scopes.</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
/// <param name="state">The scope state, ignored.</param>
|
||||
/// <returns>Always null or a no-op scope.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level being queried.</param>
|
||||
/// <returns>Always true.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Records the log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="logLevel">The severity of the entry.</param>
|
||||
/// <param name="eventId">The event identifier.</param>
|
||||
/// <param name="state">The log state.</param>
|
||||
/// <param name="exception">The associated exception, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message.</param>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
|
||||
@@ -3,13 +3,16 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SiteAuditRetentionOptions"/> clamping (PLAN-04 Task 2).
|
||||
/// Tests for <see cref="SiteAuditRetentionOptions"/> clamping.
|
||||
/// The resolved knobs guard the daily site retention job against misconfiguration:
|
||||
/// retention days are clamped to [1, 90]; the purge interval is clamped away from
|
||||
/// TimeSpan.Zero (an Akka/Timer spin footgun).
|
||||
/// </summary>
|
||||
public class SiteAuditRetentionOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that resolved retention days, clamps.</summary>
|
||||
/// <param name="configured">Test case input.</param>
|
||||
/// <param name="expected">Test case input.</param>
|
||||
[Theory]
|
||||
[InlineData(0, 1)] // clamp floor
|
||||
[InlineData(7, 7)] // default passthrough
|
||||
@@ -17,6 +20,7 @@ public class SiteAuditRetentionOptionsTests
|
||||
public void ResolvedRetentionDays_Clamps(int configured, int expected)
|
||||
=> Assert.Equal(expected, new SiteAuditRetentionOptions { RetentionDays = configured }.ResolvedRetentionDays);
|
||||
|
||||
/// <summary>Verifies that defaults, are, seven days, daily purge, five minute initial delay.</summary>
|
||||
[Fact]
|
||||
public void Defaults_Are_SevenDays_DailyPurge_FiveMinuteInitialDelay()
|
||||
{
|
||||
@@ -26,6 +30,7 @@ public class SiteAuditRetentionOptionsTests
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), o.InitialDelay);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolved purge interval, clamps zero to one minute.</summary>
|
||||
[Fact]
|
||||
public void ResolvedPurgeInterval_ClampsZeroToOneMinute()
|
||||
=> Assert.Equal(TimeSpan.FromMinutes(1),
|
||||
|
||||
+4
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
public class SiteAuditRetentionOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that the default options pass validation.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retention period of zero days fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroRetentionDays_Fails()
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a purge interval of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroPurgeInterval_Fails()
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public class SiteAuditRetentionOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative initial delay fails validation.</summary>
|
||||
[Fact]
|
||||
public void NegativeInitialDelay_Fails()
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SiteAuditRetentionService"/> (PLAN-04 Task 3). The hosted
|
||||
/// Tests for <see cref="SiteAuditRetentionService"/> The hosted
|
||||
/// service runs the site SQLite retention purge on a timer: first tick after
|
||||
/// <see cref="SiteAuditRetentionOptions.InitialDelay"/>, then every
|
||||
/// <see cref="SiteAuditRetentionOptions.ResolvedPurgeInterval"/>, passing a cutoff of
|
||||
@@ -19,10 +19,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
public class SiteAuditRetentionServiceTests
|
||||
{
|
||||
/// <summary>Verifies that stopping the service after it has been disposed does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
||||
{
|
||||
// Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync
|
||||
// Regression: Dispose tears down the CTS StopAsync
|
||||
// cancels, and the host does not guarantee StopAsync is driven before the DI
|
||||
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
||||
// escape an IHostedService aborts the host's whole shutdown sequence.
|
||||
@@ -43,6 +45,8 @@ public class SiteAuditRetentionServiceTests
|
||||
svc.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tick purges events older than the retention cutoff.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tick_Purges_With_RetentionCutoff()
|
||||
{
|
||||
@@ -66,6 +70,8 @@ public class SiteAuditRetentionServiceTests
|
||||
DateTime.UtcNow.AddDays(-7).AddMinutes(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failing tick is swallowed and later ticks still run.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Tick_SwallowsExceptions_AndKeepsTicking()
|
||||
{
|
||||
@@ -87,6 +93,8 @@ public class SiteAuditRetentionServiceTests
|
||||
Assert.True(queue.PurgeCalls.Count >= 2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stopping the service during an in-flight purge completes cleanly without surfacing a cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation()
|
||||
{
|
||||
@@ -127,6 +135,13 @@ public class SiteAuditRetentionServiceTests
|
||||
/// <summary>Stub queue that records purge cutoffs and can throw on the first tick.</summary>
|
||||
private sealed class RecordingSiteAuditQueue : ISiteAuditQueue
|
||||
{
|
||||
/// <summary>Not supported by this stub.</summary>
|
||||
/// <param name="executionIds">The execution ids to read.</param>
|
||||
/// <param name="sinceUtc">The lower time bound.</param>
|
||||
/// <param name="limit">The maximum number of rows.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <param name="afterEventId">The keyset cursor.</param>
|
||||
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
|
||||
=> throw new NotSupportedException("not exercised by the retention service");
|
||||
@@ -134,13 +149,17 @@ public class SiteAuditRetentionServiceTests
|
||||
private readonly ConcurrentQueue<DateTime> _purgeCalls = new();
|
||||
private int _calls;
|
||||
|
||||
/// <summary>Gets the cutoffs passed to each purge call, in call order.</summary>
|
||||
public IReadOnlyCollection<DateTime> PurgeCalls => _purgeCalls;
|
||||
/// <summary>Gets a value indicating whether the first purge call throws a simulated transient failure.</summary>
|
||||
public bool ThrowOnFirstCall { get; init; }
|
||||
|
||||
/// <summary>When set, PurgeExpiredAsync records the call then blocks until the token
|
||||
/// cancels — simulating a shutdown that lands while a purge is in flight.</summary>
|
||||
public bool BlockUntilCancelled { get; init; }
|
||||
|
||||
// Test stub: Records the cutoff and optionally throws or blocks, as configured.
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _calls);
|
||||
@@ -158,19 +177,41 @@ public class SiteAuditRetentionServiceTests
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Not supported by this stub.</summary>
|
||||
/// <param name="limit">The maximum number of rows.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
/// <summary>Not supported by this stub.</summary>
|
||||
/// <param name="limit">The maximum number of rows.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
// Test stub: Not supported by this stub.
|
||||
/// <inheritdoc />
|
||||
public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
/// <summary>Not supported by this stub.</summary>
|
||||
/// <param name="sinceUtc">The lower time bound.</param>
|
||||
/// <param name="batchSize">The maximum batch size.</param>
|
||||
/// <param name="afterId">The keyset cursor.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Never returns; always throws <see cref="NotSupportedException"/>.</returns>
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
|
||||
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
// Test stub: Not supported by this stub.
|
||||
/// <inheritdoc />
|
||||
public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
// Test stub: Not supported by this stub.
|
||||
/// <inheritdoc />
|
||||
public Task<int> MarkReconciledUpToAsync(DateTime sinceUtc, string? afterId, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
// Test stub: Not supported by this stub.
|
||||
/// <inheritdoc />
|
||||
public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
+5
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </summary>
|
||||
public class SiteAuditTelemetryOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that default options are valid.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -18,6 +19,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero batch size fails.</summary>
|
||||
[Fact]
|
||||
public void ZeroBatchSize_Fails()
|
||||
{
|
||||
@@ -28,6 +30,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero busy interval fails.</summary>
|
||||
[Fact]
|
||||
public void ZeroBusyInterval_Fails()
|
||||
{
|
||||
@@ -38,6 +41,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero idle interval fails.</summary>
|
||||
[Fact]
|
||||
public void ZeroIdleInterval_Fails()
|
||||
{
|
||||
@@ -48,6 +52,7 @@ public class SiteAuditTelemetryOptionsValidatorTests
|
||||
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that idle shorter than busy fails.</summary>
|
||||
[Fact]
|
||||
public void IdleShorterThanBusy_Fails()
|
||||
{
|
||||
|
||||
+14
-2
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E (M6-T6) tests for <see cref="SqliteAuditWriter.GetBacklogStatsAsync"/>.
|
||||
/// Tests for <see cref="SqliteAuditWriter.GetBacklogStatsAsync"/>.
|
||||
/// Exercises the health-metric surface that <c>SiteAuditBacklogReporter</c>
|
||||
/// polls every 30 s and pushes onto the site health report as
|
||||
/// <c>SiteAuditBacklog</c>.
|
||||
@@ -18,6 +18,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
/// <summary>Creates a unique temporary database path for each test.</summary>
|
||||
public SqliteAuditWriterBacklogStatsTests()
|
||||
{
|
||||
// OnDiskBytes assertions only make sense against a real file — the
|
||||
@@ -28,6 +29,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
$"audit-backlog-stats-{Guid.NewGuid():N}.db");
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by this test class.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
@@ -52,6 +54,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered);
|
||||
|
||||
/// <summary>Verifies that an empty database reports zero pending events, no oldest-pending timestamp and zero bytes on disk.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
|
||||
{
|
||||
@@ -70,6 +74,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
$"OnDiskBytes must be non-negative, got {snapshot.OnDiskBytes}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that five pending events are reported as a pending count of five.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Pending_5_Returns_5()
|
||||
{
|
||||
@@ -85,6 +91,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
Assert.Equal(5, snapshot.PendingCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the oldest-pending timestamp is the earliest <c>OccurredAtUtc</c> among the pending events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OldestPending_Is_Earliest_OccurredAtUtc()
|
||||
{
|
||||
@@ -110,10 +118,12 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
Assert.Equal(t1, snapshot.OldestPendingUtc!.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading the backlog stats does not block while a concurrent write load is running.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad()
|
||||
{
|
||||
// AuditLog-005: GetBacklogStatsAsync previously took _writeLock, the
|
||||
// GetBacklogStatsAsync previously took _writeLock, the
|
||||
// same lock that serialises every batch INSERT in FlushBatch. Under a
|
||||
// backlog growing to hundreds of thousands of rows a COUNT(*)+MIN
|
||||
// index scan could park the hot-path writer for hundreds of ms. The
|
||||
@@ -163,6 +173,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
||||
$"backlog probe should see at least the seeded rows; got {snapshot.PendingCount}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the on-disk byte count matches the size of the database file.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OnDiskBytes_ReturnsFileSize()
|
||||
{
|
||||
|
||||
+5
-1
@@ -14,7 +14,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// arch-review 2026-08-19 P4-2 regression coverage. <see cref="SqliteAuditWriter.FlushBatch"/>
|
||||
/// Regression coverage. <see cref="SqliteAuditWriter.FlushBatch"/>
|
||||
/// used to call <c>pending.Completion.TrySetResult()</c> per row BEFORE
|
||||
/// <c>transaction.Commit()</c> ran. A later failure in the SAME batch — another row's insert
|
||||
/// throwing, or the commit itself failing (SQLITE_FULL, IOERR) — rolled the whole transaction
|
||||
@@ -102,6 +102,8 @@ public class SqliteAuditWriterCommitFailureTests
|
||||
return (writer, dataSource);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a mid-batch insert failure faults every row in the batch, not just the failing row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FlushBatch_MidBatchInsertFailure_FaultsEveryRowInBatch_NotJustTheFailingRow()
|
||||
{
|
||||
@@ -152,6 +154,8 @@ public class SqliteAuditWriterCommitFailureTests
|
||||
Assert.Equal(0L, (long)countCmd.ExecuteScalar()!);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the fallback writer rings every faulted row of a mid-batch insert failure and increments the failure counter once per row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FallbackAuditWriter_MidBatchInsertFailure_RingsEveryFaultedRow_AndIncrementsFailureCounterForEach()
|
||||
{
|
||||
|
||||
+7
-2
@@ -10,8 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// non-positive value has a documented fall-back-to-30s contract.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation
|
||||
/// WP1.2): it defaults to <c>""</c> (no CWD-relative default — mirrors
|
||||
/// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation): it defaults to <c>""</c> (no CWD-relative default — mirrors
|
||||
/// <c>LocalDb:Path</c>), but this validator runs on both Central and Site
|
||||
/// composition roots via <c>ValidateOnStart</c>, and only Site nodes ever resolve
|
||||
/// the writer. The Site-only requirement is enforced pre-host by
|
||||
@@ -20,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
/// </remarks>
|
||||
public class SqliteAuditWriterOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that the default options pass validation.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -29,6 +29,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
||||
Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a channel capacity of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroChannelCapacity_Fails()
|
||||
{
|
||||
@@ -39,6 +40,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
||||
f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a batch size of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroBatchSize_Fails()
|
||||
{
|
||||
@@ -49,6 +51,7 @@ public class SqliteAuditWriterOptionsValidatorTests
|
||||
f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a flush interval of zero fails validation.</summary>
|
||||
[Fact]
|
||||
public void ZeroFlushInterval_Fails()
|
||||
{
|
||||
@@ -59,6 +62,8 @@ public class SqliteAuditWriterOptionsValidatorTests
|
||||
f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-positive backlog poll interval still passes validation.</summary>
|
||||
/// <param name="value">The non-positive poll interval, in milliseconds, to validate.</param>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
|
||||
+13
@@ -19,6 +19,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db");
|
||||
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } }
|
||||
@@ -33,6 +34,9 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
|
||||
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
|
||||
|
||||
/// <summary>Verifies that the trace read plan uses the execution-id indexes instead of scanning the retained store.</summary>
|
||||
/// <param name="idCount">The number of execution ids included in the read.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(64)]
|
||||
@@ -56,6 +60,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that legacy rows with malformed details JSON do not break startup or trace reads.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads()
|
||||
{
|
||||
@@ -81,6 +87,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the cursor pages through rows with equal timestamps without repeating or skipping any.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows()
|
||||
{
|
||||
@@ -100,6 +108,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
first.Concat(second).Select(e => e.EventId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rows matching either the execution id or the parent execution id are returned in order and within the bound.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
|
||||
{
|
||||
@@ -145,6 +155,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
/// ApiOutbound row (or any other channel) stored under the execution id is served alongside
|
||||
/// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ServesOutboundRows_NoChannelPredicate()
|
||||
{
|
||||
@@ -171,6 +182,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read guards reject an invalid limit and an oversized id list.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Guards_LimitAndIdCap()
|
||||
{
|
||||
|
||||
@@ -22,12 +22,14 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
/// <summary>Initializes the test class with a unique temporary database path.</summary>
|
||||
public SqliteAuditWriterRetentionTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(),
|
||||
$"audit-retention-{Guid.NewGuid():N}.db");
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
@@ -58,6 +60,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that purging deletes forwarded and reconciled rows older than the cutoff.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PurgeExpired_DeletesForwardedAndReconciled_OlderThanCutoff()
|
||||
{
|
||||
@@ -80,6 +84,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable
|
||||
Assert.Equal(1, stats.PendingCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that purging expired rows is idempotent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PurgeExpired_IsIdempotent()
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// C4 (Task 2.5) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>'s
|
||||
/// Schema-bootstrap tests for <see cref="SqliteAuditWriter"/>'s
|
||||
/// two-table site schema — the append-only canonical <c>audit_event</c> table +
|
||||
/// the mutable operational <c>audit_forward_state</c> sidecar + the <c>IX_fwd</c>
|
||||
/// drain index. Uses an in-memory shared-cache SQLite database so the same
|
||||
@@ -80,6 +80,7 @@ public class SqliteAuditWriterSchemaTests
|
||||
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opens, creates, audit, event, canonical, table, with, 10 columns, and, pk, on, event id.</summary>
|
||||
[Fact]
|
||||
public void Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId()
|
||||
{
|
||||
@@ -113,6 +114,7 @@ public class SqliteAuditWriterSchemaTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opens, creates, audit, forward, state, sidecar, table, with, expected, columns.</summary>
|
||||
[Fact]
|
||||
public void Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns()
|
||||
{
|
||||
@@ -145,6 +147,7 @@ public class SqliteAuditWriterSchemaTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opens, creates, ix, fwd, index, on, forward state, is cached kind, occurred.</summary>
|
||||
[Fact]
|
||||
public void Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred()
|
||||
{
|
||||
@@ -180,6 +183,7 @@ public class SqliteAuditWriterSchemaTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pragma, auto, vacuum, is, incremental.</summary>
|
||||
[Fact]
|
||||
public void PRAGMA_auto_vacuum_Is_INCREMENTAL()
|
||||
{
|
||||
@@ -265,6 +269,8 @@ public class SqliteAuditWriterSchemaTests
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening, over, pre existing, old single table, db, drops, it, and, creates, two, table, schema.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema()
|
||||
{
|
||||
@@ -316,6 +322,8 @@ public class SqliteAuditWriterSchemaTests
|
||||
|
||||
// ----- Canonical / sidecar field persistence ----- //
|
||||
|
||||
/// <summary>Verifies that write async, persists, source node, field.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_persists_SourceNode_field()
|
||||
{
|
||||
@@ -337,6 +345,8 @@ public class SqliteAuditWriterSchemaTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write async, persists, null, source node.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_persists_null_SourceNode()
|
||||
{
|
||||
@@ -358,6 +368,8 @@ public class SqliteAuditWriterSchemaTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write async, execution id, round trips, through, details json.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson()
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// C4 (Task 2.5) hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s
|
||||
/// Hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s
|
||||
/// two-table site schema. Exercise the Channel-based enqueue, the background
|
||||
/// writer's per-event canonical(<c>audit_event</c>) + sidecar
|
||||
/// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the
|
||||
@@ -87,7 +87,7 @@ public class SqliteAuditWriterWriteTests
|
||||
return byState;
|
||||
}
|
||||
|
||||
// C4 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||||
// Build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||||
// factory. The SQLite writer stores the 10 canonical fields directly in
|
||||
// audit_event and writes a Pending sidecar row into audit_forward_state, with
|
||||
// IsCachedKind precomputed from the event's Kind. Reads recompose the canonical
|
||||
@@ -120,6 +120,8 @@ public class SqliteAuditWriterWriteTests
|
||||
eventId: id ?? Guid.NewGuid(),
|
||||
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow);
|
||||
|
||||
/// <summary>Verifies that a fresh event persists its canonical fields and a Pending sidecar row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending()
|
||||
{
|
||||
@@ -140,6 +142,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the canonical fields written for an event round-trip unchanged through a read.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read()
|
||||
{
|
||||
@@ -164,6 +168,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(evt.DetailsJson, row.DetailsJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that 1000 concurrent writes all persist without throwing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions()
|
||||
{
|
||||
@@ -186,16 +192,17 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
|
||||
}
|
||||
|
||||
// ----- FlushIntervalMs soft-flush coalescing (arch-review remediation WP1.2) ----- //
|
||||
// ----- FlushIntervalMs soft-flush coalescing (arch-review remediation) ----- //
|
||||
|
||||
/// <summary>
|
||||
/// A trickle of events — well under <see cref="SqliteAuditWriterOptions.BatchSize"/>,
|
||||
/// spaced closer together than <see cref="SqliteAuditWriterOptions.FlushIntervalMs"/> —
|
||||
/// must coalesce into ONE transaction rather than fsyncing once per event. This is the
|
||||
/// bug WP1.2 fixes: <c>FlushIntervalMs</c> was validated but never read by the writer
|
||||
/// bug this fixes: <c>FlushIntervalMs</c> was validated but never read by the writer
|
||||
/// loop, so every trickle-rate write incurred its own commit (and, under SQLite's
|
||||
/// default synchronous=FULL, its own fsync).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_TrickleLoad_CoalescesIntoOneTransaction()
|
||||
{
|
||||
@@ -229,6 +236,7 @@ public class SqliteAuditWriterWriteTests
|
||||
/// waiting for a partner — the deadline bounds worst-case added latency, it does
|
||||
/// not turn into an unbounded debounce.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_EventsSpacedBeyondFlushInterval_FlushSeparately()
|
||||
{
|
||||
@@ -249,6 +257,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(2, writer.FlushCountForTests);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a duplicate event id is swallowed without an exception and the first write wins.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException()
|
||||
{
|
||||
@@ -281,13 +291,15 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal("first", targetCmd.ExecuteScalar() as string);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a write always sets the sidecar forward state to Pending.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_ForcesSidecarForwardStatePending()
|
||||
{
|
||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending));
|
||||
await using var _ = writer;
|
||||
|
||||
// C4 (Task 2.5): ForwardState is not a field on the canonical record; a
|
||||
// ForwardState is not a field on the canonical record; a
|
||||
// fresh event's sidecar row defaults to Pending on INSERT.
|
||||
var evt = NewEvent();
|
||||
await writer.WriteAsync(evt);
|
||||
@@ -297,6 +309,8 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
// ----- IsCachedKind drain split (precomputed at insert) ----- //
|
||||
|
||||
/// <summary>Verifies that a cached audit kind sets the cached-kind flag to 1 and a non-cached kind sets it to 0.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0()
|
||||
{
|
||||
@@ -320,6 +334,9 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cached audit kinds drain through the cached-telemetry read and not the ordinary pending read.</summary>
|
||||
/// <param name="kind">The cached audit kind under test.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(AuditKind.CachedSubmit)]
|
||||
[InlineData(AuditKind.ApiCallCached)]
|
||||
@@ -342,6 +359,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-cached audit kind drains through the ordinary pending read and not the cached-telemetry read.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry()
|
||||
{
|
||||
@@ -358,6 +377,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the pending read returns the oldest rows first, limited to the requested count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN()
|
||||
{
|
||||
@@ -387,6 +408,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that marking events as forwarded flips their sidecar rows to Forwarded.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded()
|
||||
{
|
||||
@@ -406,6 +429,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that marking events as forwarded increments the attempt count and stamps the last-attempt time.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc()
|
||||
{
|
||||
@@ -428,6 +453,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped
|
||||
}
|
||||
|
||||
/// <summary>Verifies that marking a non-existent event id as forwarded does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkForwardedAsync_NonExistentId_NoThrow()
|
||||
{
|
||||
@@ -440,6 +467,8 @@ public class SqliteAuditWriterWriteTests
|
||||
// No assertion needed: the call must complete without throwing.
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the forwarded read returns only rows in the Forwarded state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows()
|
||||
{
|
||||
@@ -457,8 +486,10 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(forwarded.EventId, row.EventId);
|
||||
}
|
||||
|
||||
// ----- M6 reconciliation pull surface ----- //
|
||||
// ----- Reconciliation pull surface ----- //
|
||||
|
||||
/// <summary>Verifies that the since-read returns pending and forwarded rows oldest first, limited to the requested count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN()
|
||||
{
|
||||
@@ -488,6 +519,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the since-read excludes rows older than the supplied cutoff.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc()
|
||||
{
|
||||
@@ -511,6 +544,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.DoesNotContain(rows, r => r.EventId == old.EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the since-read excludes rows that are already reconciled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_ExcludesReconciledRows()
|
||||
{
|
||||
@@ -531,8 +566,10 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(pending.EventId, rows[0].EventId);
|
||||
}
|
||||
|
||||
// ----- WP2.3: composite keyset cursor + cursor-proved retirement ----- //
|
||||
// ----- Composite keyset cursor + cursor-proved retirement ----- //
|
||||
|
||||
/// <summary>Verifies that supplying an after-id skips rows at or before the composite timestamp-and-id cursor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_WithAfterId_SkipsRowsAtOrBeforeTheCompositeCursor()
|
||||
{
|
||||
@@ -567,6 +604,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(sameInstant.Select(e => e.EventId).ToHashSet(), allIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reconciling up to a cursor with an id retires only rows at or before that cursor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkReconciledUpToAsync_WithCursorId_RetiresOnlyRowsAtOrBeforeIt()
|
||||
{
|
||||
@@ -599,6 +638,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(3, remaining.Count); // the two un-consumed at the instant + newer
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reconciling up to a timestamp without a cursor id leaves rows at the boundary instant servable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkReconciledUpToAsync_WithoutCursorId_LeavesTheBoundaryInstantServable()
|
||||
{
|
||||
@@ -626,6 +667,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(boundary.EventId, row.EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rows served without an advancing cursor remain servable so they are delivered at least once.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ServingRows_WithoutAnAdvancingCursor_KeepsThemServable_AtLeastOnce()
|
||||
{
|
||||
@@ -652,6 +695,8 @@ public class SqliteAuditWriterWriteTests
|
||||
second.Select(r => r.EventId).ToHashSet());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a late-stamped insert below the cursor is never retired by reconciling up to the cursor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkReconciledUpToAsync_LateStampedInsertBelowTheCursor_IsNeverRetired()
|
||||
{
|
||||
@@ -695,6 +740,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(lateStamped.EventId, Assert.Single(reread).EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that forwarded rows are retired by reconciling up to a cursor even if they were never pulled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkReconciledUpToAsync_ForwardedRowsRetire_EvenWithoutHavingBeenPulled()
|
||||
{
|
||||
@@ -722,6 +769,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, neverShipped.EventId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reconciling before any pull retires no pending rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkReconciledUpToAsync_BeforeAnyPull_RetiresNothingPending()
|
||||
{
|
||||
@@ -745,6 +794,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(1, await writer.MarkReconciledUpToAsync(t0.AddHours(1), afterId: null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the since-read throws for an invalid batch size.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws()
|
||||
{
|
||||
@@ -757,6 +808,8 @@ public class SqliteAuditWriterWriteTests
|
||||
() => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that marking events as reconciled flips both pending and forwarded rows to Reconciled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled()
|
||||
{
|
||||
@@ -781,6 +834,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that marking reconciled is idempotent and leaves already-reconciled rows untouched.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched()
|
||||
{
|
||||
@@ -796,6 +851,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that marking a non-existent event id as reconciled does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkReconciledAsync_NonExistentId_NoThrow()
|
||||
{
|
||||
@@ -807,7 +864,7 @@ public class SqliteAuditWriterWriteTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix 2 / M1 state guard: <see cref="SqliteAuditWriter.MarkForwardedAsync"/>
|
||||
/// State guard: <see cref="SqliteAuditWriter.MarkForwardedAsync"/>
|
||||
/// must NOT demote a <see cref="AuditForwardState.Reconciled"/> row back to
|
||||
/// <see cref="AuditForwardState.Forwarded"/>. When a batch contains both a
|
||||
/// Pending ID and an already-Reconciled ID:
|
||||
@@ -818,6 +875,7 @@ public class SqliteAuditWriterWriteTests
|
||||
/// This mirrors the idempotency guard already present on
|
||||
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions()
|
||||
{
|
||||
@@ -879,6 +937,7 @@ public class SqliteAuditWriterWriteTests
|
||||
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> call flip atomically — and
|
||||
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> chunks the same way.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip()
|
||||
{
|
||||
@@ -912,6 +971,8 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
|
||||
|
||||
/// <summary>Verifies that a non-null execution id round-trips through a write and read.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_NonNullExecutionId_RoundTrips()
|
||||
{
|
||||
@@ -928,6 +989,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null execution id round-trips as null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
|
||||
{
|
||||
@@ -945,6 +1008,8 @@ public class SqliteAuditWriterWriteTests
|
||||
|
||||
// ----- SourceNode stamping (Tasks 11/12) ----- //
|
||||
|
||||
/// <summary>Verifies that the source node is stamped from the node identity provider when the event has none.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
|
||||
{
|
||||
@@ -962,6 +1027,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal("node-a", row.SourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a source node supplied by the caller is preserved.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
|
||||
{
|
||||
@@ -980,6 +1047,8 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal("node-z", row.SourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the source node stays null when the provider returns null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
|
||||
{
|
||||
@@ -999,13 +1068,14 @@ public class SqliteAuditWriterWriteTests
|
||||
// ----- C4 hardening: safe enum-parse in MapRow ----- //
|
||||
|
||||
/// <summary>
|
||||
/// C4 hardening (Task 2.5): a row whose stored <c>Outcome</c> column holds an
|
||||
/// C4 hardening: a row whose stored <c>Outcome</c> column holds an
|
||||
/// unknown/renamed enum string must NOT fault the read path; it degrades
|
||||
/// gracefully to <see cref="AuditOutcome.Success"/> (the safe
|
||||
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is
|
||||
/// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/>
|
||||
/// surface which calls the private <c>MapRow</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback()
|
||||
{
|
||||
@@ -1033,8 +1103,10 @@ public class SqliteAuditWriterWriteTests
|
||||
Assert.Equal(AuditOutcome.Success, row.Outcome);
|
||||
}
|
||||
|
||||
// ── Task 24: pin the inclusive (>=) reconciliation cursor boundary ──
|
||||
// ── Pin the inclusive (>=) reconciliation cursor boundary ──
|
||||
|
||||
/// <summary>Verifies that a row at the boundary timestamp is included in the since-read and that event-id deduplication absorbs the resulting duplicates.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates()
|
||||
{
|
||||
|
||||
+39
-7
@@ -53,6 +53,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
SourceScript: sourceScript,
|
||||
ParentExecutionId: parentExecutionId);
|
||||
|
||||
/// <summary>Verifies that a transient failure emits one Attempted row and no resolve row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
|
||||
{
|
||||
@@ -78,6 +80,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a delivered attempt emits an Attempted row and a CachedResolve row with Delivered status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered()
|
||||
{
|
||||
@@ -104,6 +108,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permanent failure emits an Attempted row and a CachedResolve row with Parked status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked()
|
||||
{
|
||||
@@ -123,6 +129,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal("Parked", captured[1].Operational.Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parking after the maximum retries emits an Attempted row and a CachedResolve row with Parked status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked()
|
||||
{
|
||||
@@ -138,6 +146,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the database channel maps to the DbWriteCached kind and the DbOutbound channel.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel()
|
||||
{
|
||||
@@ -157,6 +167,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the bridge does not throw when the telemetry forwarder throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BridgeDoesNotThrow_WhenForwarderThrows()
|
||||
{
|
||||
@@ -170,6 +182,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the bridge populates provenance fields from the attempt context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BridgePopulatesProvenance_FromAttemptContext()
|
||||
{
|
||||
@@ -192,12 +206,14 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId);
|
||||
}
|
||||
|
||||
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
|
||||
// ── ExecutionId / SourceScript ──
|
||||
|
||||
/// <summary>Verifies that a retry-loop Attempted row carries the ExecutionId and SourceScript from the attempt context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext()
|
||||
{
|
||||
// Task 4: the ExecutionId + SourceScript threaded through the S&F
|
||||
// The ExecutionId + SourceScript threaded through the S&F
|
||||
// buffer arrive on the CachedCallAttemptContext; the bridge must stamp
|
||||
// both onto the per-attempt ApiCallCached row (previously SourceScript
|
||||
// was hard-coded null with a "not threaded through S&F" comment).
|
||||
@@ -218,6 +234,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retry-loop CachedResolve row carries the ExecutionId and SourceScript from the attempt context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
|
||||
{
|
||||
@@ -245,10 +263,12 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retry-loop row leaves ExecutionId and SourceScript null when the context has none.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull()
|
||||
{
|
||||
// Back-compat: a pre-Task-4 buffered row has no ExecutionId /
|
||||
// Back-compat: a buffered row written before ExecutionId was threaded through has no ExecutionId /
|
||||
// SourceScript; the bridge must leave the audit row's fields null
|
||||
// rather than throwing.
|
||||
CachedCallTelemetry? captured = null;
|
||||
@@ -263,12 +283,14 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Null(captured.Audit.AsRow().SourceScript);
|
||||
}
|
||||
|
||||
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
|
||||
// ── ParentExecutionId ──
|
||||
|
||||
/// <summary>Verifies that a retry-loop Attempted row carries the ParentExecutionId from the attempt context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext()
|
||||
{
|
||||
// Task 6: the ParentExecutionId threaded through the S&F buffer (the
|
||||
// The ParentExecutionId threaded through the S&F buffer (the
|
||||
// inbound-API run that spawned the originating script) arrives on the
|
||||
// CachedCallAttemptContext; the bridge must stamp it onto the
|
||||
// per-attempt ApiCallCached row beside ExecutionId.
|
||||
@@ -287,6 +309,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retry-loop CachedResolve row carries the ParentExecutionId from the attempt context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext()
|
||||
{
|
||||
@@ -312,6 +336,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retry-loop row leaves ParentExecutionId null when the context has none.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopRow_NullParentExecutionId_RemainsNull()
|
||||
{
|
||||
@@ -329,12 +355,14 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Null(captured!.Audit.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
// ── SourceNode-stamping (Task 14) ──
|
||||
// ── SourceNode stamping ──
|
||||
|
||||
/// <summary>Verifies that a retry-loop row stamps the source node name from the node identity provider.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopRow_StampsSourceNode_FromNodeIdentityProvider()
|
||||
{
|
||||
// SourceNode-stamping (Task 14): when an INodeIdentityProvider is
|
||||
// SourceNode stamping: when an INodeIdentityProvider is
|
||||
// wired the bridge stamps the local node name (node-a/node-b) onto
|
||||
// the SiteCallOperational.SourceNode column of every emitted packet.
|
||||
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
|
||||
@@ -353,6 +381,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.All(captured, p => Assert.Equal("node-a", p.Operational.SourceNode));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retry-loop row leaves the source node null when no node identity provider is configured.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull()
|
||||
{
|
||||
@@ -371,6 +401,8 @@ public class CachedCallLifecycleBridgeTests
|
||||
Assert.Null(packet.Operational.SourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a retry-loop row leaves the source node null when the node identity has a null node name.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull()
|
||||
{
|
||||
|
||||
+23
-3
@@ -111,6 +111,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
UpdatedAtUtc: _now,
|
||||
TerminalAtUtc: _now));
|
||||
|
||||
/// <summary>Verifies that forwarding a submit packet writes an audit event and records the enqueue.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue()
|
||||
{
|
||||
@@ -130,7 +132,7 @@ public class CachedCallTelemetryForwarderTests
|
||||
// Tracking row: insert-if-not-exists with kind discriminator.
|
||||
// Default CreateSut() does NOT supply an INodeIdentityProvider, so the
|
||||
// forwarder passes null sourceNode to RecordEnqueueAsync (legacy / test
|
||||
// host behaviour). The Task 14 stamping path is covered by the
|
||||
// host behaviour). The stamping path is covered by the
|
||||
// ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider test
|
||||
// below.
|
||||
await _tracking.Received(1).RecordEnqueueAsync(
|
||||
@@ -147,6 +149,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
default, default!, default, default, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that forwarding an attempted packet writes an audit event and records the attempt.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt()
|
||||
{
|
||||
@@ -170,6 +174,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
default, default!, default, default, default);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that forwarding a resolve packet writes an audit event and records the terminal state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal()
|
||||
{
|
||||
@@ -196,6 +202,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
// ── 2026-09-06: the audit row is enqueued, never awaited to commit ─────
|
||||
// Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md
|
||||
|
||||
/// <summary>Verifies that forwarding returns while the audit commit is still pending.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_ReturnsWhileTheAuditCommitIsStillPending()
|
||||
{
|
||||
@@ -216,6 +224,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
_id, "Delivered", Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a later audit commit fault is swallowed rather than surfaced to the caller.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_AuditCommitFaultsLater_IsSwallowed()
|
||||
{
|
||||
@@ -234,6 +244,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing audit writer is logged and the exception does not propagate.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate()
|
||||
{
|
||||
@@ -257,6 +269,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing tracking store is logged and the exception does not propagate.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate()
|
||||
{
|
||||
@@ -279,6 +293,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that forwarding a null packet throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_NullPacket_Throws()
|
||||
{
|
||||
@@ -288,12 +304,14 @@ public class CachedCallTelemetryForwarderTests
|
||||
() => sut.ForwardAsync(null!, CancellationToken.None));
|
||||
}
|
||||
|
||||
// ── SourceNode-stamping (Task 14) ──
|
||||
// ── SourceNode stamping ──
|
||||
|
||||
/// <summary>Verifies that a submit packet is stamped with the source node from the node identity provider.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider()
|
||||
{
|
||||
// SourceNode-stamping (Task 14): when an INodeIdentityProvider is
|
||||
// SourceNode stamping: when an INodeIdentityProvider is
|
||||
// wired the forwarder must stamp its NodeName onto the
|
||||
// RecordEnqueueAsync sourceNode parameter so the tracking row
|
||||
// captures the originating node (node-a/node-b).
|
||||
@@ -315,6 +333,8 @@ public class CachedCallTelemetryForwarderTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a node identity with a null node name results in a null source node.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode()
|
||||
{
|
||||
|
||||
+10
-4
@@ -4,15 +4,17 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E E1 tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
|
||||
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until M6
|
||||
/// delivers the gRPC-backed implementation; both <c>IngestAuditEventsAsync</c>
|
||||
/// (M2) and <c>IngestCachedTelemetryAsync</c> (M3) must return an empty ack
|
||||
/// Tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
|
||||
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until the
|
||||
/// gRPC-backed implementation is delivered; both <c>IngestAuditEventsAsync</c>
|
||||
/// and <c>IngestCachedTelemetryAsync</c> must return an empty ack
|
||||
/// (no rows flipped to Forwarded) without throwing or partially handling the
|
||||
/// batch.
|
||||
/// </summary>
|
||||
public class NoOpSiteStreamAuditClientTests
|
||||
{
|
||||
/// <summary>Verifies that ingesting an empty cached-telemetry batch returns an empty acknowledgement.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck()
|
||||
{
|
||||
@@ -25,6 +27,8 @@ public class NoOpSiteStreamAuditClientTests
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ingesting a populated batch still returns an empty acknowledgement, because the no-op client forwards nothing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck()
|
||||
{
|
||||
@@ -47,6 +51,8 @@ public class NoOpSiteStreamAuditClientTests
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ingesting a null batch throws an <see cref="ArgumentNullException"/>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetryAsync_NullBatch_Throws()
|
||||
{
|
||||
|
||||
+31
-5
@@ -55,7 +55,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
(IOperationTrackingStore?)null)));
|
||||
|
||||
/// <summary>
|
||||
/// AuditLog-001: builds an actor with the optional
|
||||
/// Builds an actor with the optional
|
||||
/// <see cref="IOperationTrackingStore"/> wired in so the cached-drain
|
||||
/// scheduler is armed alongside the audit-only drain. Used by the new
|
||||
/// cached-drain regression tests below.
|
||||
@@ -86,6 +86,9 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
return ack;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a drain of large rows stays within the wire budget and does not acknowledge rows that were not sent.</summary>
|
||||
/// <param name="cached"><c>true</c> to drain cached-call rows, <c>false</c> to drain audit-only rows.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -126,6 +129,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Assert.Equal(256, sent.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a drain of 50 pending rows sends one batch of 50 and then marks the rows as forwarded.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded()
|
||||
{
|
||||
@@ -166,6 +171,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rows stay pending when the gRPC call throws and the next drain retries them.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries()
|
||||
{
|
||||
@@ -206,6 +213,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a drain with no pending rows schedules the next drain at the idle interval without calling the client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall()
|
||||
{
|
||||
@@ -230,6 +239,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Assert.InRange(readCalls, 1, 2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a drain that finds pending rows schedules the next drain at the busy interval.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Drain_NonZeroPending_SchedulesAtBusyInterval()
|
||||
{
|
||||
@@ -254,6 +265,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
$"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that only the event ids the central ack accepted are marked as forwarded.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted()
|
||||
{
|
||||
@@ -292,7 +305,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// AuditLog-001: combined-telemetry cached-drain regression tests. Verify
|
||||
// Combined-telemetry cached-drain regression tests. Verify
|
||||
// that the production wiring of the previously-unreachable cached transport
|
||||
// routes cached rows through ReadPendingCachedTelemetryAsync +
|
||||
// IngestCachedTelemetryAsync (and NOT IngestAuditEventsAsync), and that
|
||||
@@ -305,7 +318,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Guid? eventId = null,
|
||||
Guid? correlationId = null,
|
||||
string sourceSiteId = "site-1") =>
|
||||
// C3 (Task 2.5): canonical record via the shared factory; ForwardState is
|
||||
// Canonical record via the shared factory; ForwardState is
|
||||
// no longer a record field (the SQLite shim defaults it on INSERT).
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
@@ -335,12 +348,14 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
SourceScript: "script-1",
|
||||
SourceNode: "node-a");
|
||||
|
||||
/// <summary>Verifies that cached rows are sent through the cached-telemetry RPC rather than the audit-events RPC.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedDrain_CachedRows_RouteThrough_IngestCachedTelemetry_NotIngestAuditEvents()
|
||||
{
|
||||
// Arrange — three cached audit rows on the cached queue, each with a
|
||||
// matching tracking snapshot. The audit-only queue is empty (those
|
||||
// rows are excluded by ReadPendingAsync after AuditLog-001).
|
||||
// rows are excluded by ReadPendingAsync after the combined-telemetry change).
|
||||
var cachedRows = new[]
|
||||
{
|
||||
NewCachedEvent(AuditKind.CachedSubmit),
|
||||
@@ -401,6 +416,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Assert.Equal(expectedIds, emittedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached row with no tracking snapshot past the grace period is abandoned while a valid row still flows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedDrain_OrphanRow_PastGrace_IsAbandoned_AndTheValidRowStillFlows()
|
||||
{
|
||||
@@ -494,6 +511,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Assert.Equal(valid.EventId.ToString(), capturedBatch.Packets[0].AuditEvent.EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached row with no tracking snapshot inside the grace period is retried rather than abandoned.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedDrain_OrphanRow_InsideGrace_IsRetried_NotAbandoned()
|
||||
{
|
||||
@@ -536,6 +555,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a full batch of unresolvable cached rows does not starve the rest of the queue.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue()
|
||||
{
|
||||
@@ -575,6 +596,8 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tracking-store failure does not cause the cached row to be abandoned.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedDrain_TrackingStoreThrow_DoesNotAbandonTheRow()
|
||||
{
|
||||
@@ -608,13 +631,15 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that audit-only rows still drain when the cached drain is disabled.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditOnlyDrain_StillFlows_When_CachedDrain_IsDisabled()
|
||||
{
|
||||
// Arrange — ordinary (non-cached) audit rows on the audit-only queue;
|
||||
// the actor is constructed WITHOUT a tracking store so the cached
|
||||
// scheduler is never armed. Regression guard against the audit-only
|
||||
// drain regressing during the AuditLog-001 refactor.
|
||||
// drain regressing during the combined-telemetry refactor.
|
||||
var rows = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
|
||||
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
@@ -655,6 +680,7 @@ public class SiteAuditTelemetryActorTests : TestKit
|
||||
/// test in this class masks the bug by returning already-completed tasks
|
||||
/// from the mocks, which keeps the continuations on the actor thread.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Drains_Whose_Awaits_Complete_Off_The_Actor_Thread_Keep_Draining_Without_Crashing()
|
||||
{
|
||||
|
||||
+13
-1
@@ -61,6 +61,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)),
|
||||
};
|
||||
|
||||
/// <summary>Verifies that a full acknowledgement maps all accepted event ids onto the returned ack.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck()
|
||||
{
|
||||
@@ -88,6 +90,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
ack.AcceptedEventIds.ToHashSet());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a partial acknowledgement lists only the accepted event ids on the returned ack.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck()
|
||||
{
|
||||
@@ -110,6 +114,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
ack.AcceptedEventIds.ToHashSet());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an Ask timeout throws so the drain loop keeps its rows pending.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending()
|
||||
{
|
||||
@@ -127,6 +133,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => task);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a faulted reply causes the ingest call to throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestAuditEventsAsync_FaultedReply_Throws()
|
||||
{
|
||||
@@ -136,13 +144,15 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None);
|
||||
probe.ExpectMsg<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
|
||||
|
||||
// A Status.Failure from central (Task 1: central does not swallow an
|
||||
// A Status.Failure from central (central does not swallow an
|
||||
// ingest fault into an empty ack) must propagate as a thrown exception.
|
||||
probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted")));
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => task);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cached telemetry is routed as a command and the reply is mapped back.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply()
|
||||
{
|
||||
@@ -178,6 +188,8 @@ public class SiteCommunicationAuditClientTests : TestKit
|
||||
ack.AcceptedEventIds.ToHashSet());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an Ask timeout during cached telemetry ingestion throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetryAsync_AskTimeout_Throws()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user