Merge branch 'worktree-agent-aae48b78656e5a4e0' into arch-review-remediation

# Conflicts:
#	src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs
This commit is contained in:
Joseph Doherty
2026-08-14 21:16:11 -04:00
32 changed files with 1361 additions and 120 deletions
@@ -104,3 +104,78 @@ public class ManagementHttpClientTests
Assert.Equal("TIMEOUT", response.ErrorCode);
}
}
/// <summary>
/// WP2.6e (arch-review misc — CLI HttpClient timeout): the public
/// <see cref="ManagementHttpClient"/> constructor must bound its underlying
/// <see cref="HttpClient.Timeout"/> explicitly (30 s default) rather than leaving the
/// framework's 100 s default in place, and must honor the
/// <c>SCADABRIDGE_HTTP_TIMEOUT_SECONDS</c> override — consistent with how every other
/// CLI setting is environment-overridable (<see cref="CliConfig"/>). Runs in the shared
/// "Environment" collection (see <see cref="TestCollections"/>) so it never races another
/// test mutating process-wide environment variables.
/// </summary>
[Collection("Environment")]
public class ManagementHttpClientTimeoutTests
{
private const string EnvVar = "SCADABRIDGE_HTTP_TIMEOUT_SECONDS";
[Fact]
public void DefaultConstructor_SetsThirtySecondTimeout_WhenEnvVarUnset()
{
var original = Environment.GetEnvironmentVariable(EnvVar);
try
{
Environment.SetEnvironmentVariable(EnvVar, null);
using var client = new ManagementHttpClient("http://localhost:9001", "user", "pass");
Assert.Equal(TimeSpan.FromSeconds(30), ManagementHttpClient.DefaultTimeout);
Assert.Equal(TimeSpan.FromSeconds(30), client.EffectiveTimeout);
}
finally
{
Environment.SetEnvironmentVariable(EnvVar, original);
}
}
[Theory]
[InlineData("0")]
[InlineData("-5")]
[InlineData("not-a-number")]
[InlineData("")]
public void InvalidOrNonPositiveEnvValue_FallsBackToDefault(string value)
{
var original = Environment.GetEnvironmentVariable(EnvVar);
try
{
Environment.SetEnvironmentVariable(EnvVar, value);
using var client = new ManagementHttpClient("http://localhost:9001", "user", "pass");
Assert.Equal(TimeSpan.FromSeconds(30), client.EffectiveTimeout);
}
finally
{
Environment.SetEnvironmentVariable(EnvVar, original);
}
}
[Fact]
public void PositiveEnvValue_OverridesDefaultTimeout()
{
var original = Environment.GetEnvironmentVariable(EnvVar);
try
{
Environment.SetEnvironmentVariable(EnvVar, "5");
using var client = new ManagementHttpClient("http://localhost:9001", "user", "pass");
Assert.Equal(TimeSpan.FromSeconds(5), client.EffectiveTimeout);
}
finally
{
Environment.SetEnvironmentVariable(EnvVar, original);
}
}
}
@@ -0,0 +1,110 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// WP2.6b (arch-review misc — Inbound API per-request SQL): <see cref="ApiMethodCache"/>
/// hit/miss/expiry/invalidation behavior, independent of the endpoint and subscriber wiring
/// (covered separately by <see cref="ScriptArtifactChangeSubscriberTests"/>).
/// </summary>
public class ApiMethodCacheTests
{
private static ApiMethod Method(string name) => new(name, "return 1;") { Id = 1 };
[Fact]
public async Task GetOrFetchAsync_SecondCallWithinTtl_ServesFromCache_NoRefetch()
{
var cache = new ApiMethodCache(TimeSpan.FromMinutes(5));
var fetchCount = 0;
Task<ApiMethod?> Fetch(CancellationToken _)
{
fetchCount++;
return Task.FromResult<ApiMethod?>(Method("m"));
}
var first = await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
var second = await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
Assert.Equal(1, fetchCount);
Assert.Same(first, second);
}
[Fact]
public async Task GetOrFetchAsync_AfterTtlExpiry_Refetches()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var cache = new ApiMethodCache(TimeSpan.FromSeconds(1), time);
var fetchCount = 0;
Task<ApiMethod?> Fetch(CancellationToken _)
{
fetchCount++;
return Task.FromResult<ApiMethod?>(Method("m"));
}
await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
time.Advance(TimeSpan.FromSeconds(2));
await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
Assert.Equal(2, fetchCount);
}
[Fact]
public async Task GetOrFetchAsync_NegativeResult_IsCachedUntilTtlExpires()
{
var cache = new ApiMethodCache(TimeSpan.FromMinutes(5));
var fetchCount = 0;
Task<ApiMethod?> Fetch(CancellationToken _)
{
fetchCount++;
return Task.FromResult<ApiMethod?>(null);
}
var first = await cache.GetOrFetchAsync("missing", Fetch, CancellationToken.None);
var second = await cache.GetOrFetchAsync("missing", Fetch, CancellationToken.None);
Assert.Null(first);
Assert.Null(second);
Assert.Equal(1, fetchCount);
}
[Fact]
public async Task Invalidate_DropsEntry_NextCallRefetches()
{
var cache = new ApiMethodCache(TimeSpan.FromMinutes(5));
var fetchCount = 0;
Task<ApiMethod?> Fetch(CancellationToken _)
{
fetchCount++;
return Task.FromResult<ApiMethod?>(Method("m"));
}
await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
cache.Invalidate("m");
await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
Assert.Equal(2, fetchCount);
}
[Fact]
public void Invalidate_UnknownName_DoesNotThrow()
{
var cache = new ApiMethodCache(TimeSpan.FromMinutes(5));
cache.Invalidate("never-cached");
}
[Fact]
public void Constructor_NonPositiveTtl_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new ApiMethodCache(TimeSpan.Zero));
Assert.Throws<ArgumentOutOfRangeException>(() => new ApiMethodCache(TimeSpan.FromSeconds(-1)));
}
/// <summary>Minimal controllable <see cref="TimeProvider"/> for TTL-expiry tests.</summary>
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset start) => _now = start;
public void Advance(TimeSpan by) => _now += by;
public override DateTimeOffset GetUtcNow() => _now;
}
}
@@ -65,4 +65,22 @@ public class InboundApiOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("MaxRequestBodyBytes", result.FailureMessage);
}
[Fact]
public void ZeroApiMethodCacheTtl_IsRejected()
{
var result = Validate(new InboundApiOptions { ApiMethodCacheTtl = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("ApiMethodCacheTtl", result.FailureMessage);
}
[Fact]
public void NegativeApiMethodCacheTtl_IsRejected()
{
var result = Validate(new InboundApiOptions { ApiMethodCacheTtl = TimeSpan.FromSeconds(-1) });
Assert.True(result.Failed);
Assert.Contains("ApiMethodCacheTtl", result.FailureMessage);
}
}
@@ -41,12 +41,13 @@ public class ScriptArtifactChangeSubscriberTests
private readonly InboundScriptExecutor _executor = new(
NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
private readonly ApiMethodCache _methodCache = new(TimeSpan.FromMinutes(5));
private readonly RecordingBus _bus = new();
private readonly RouteHelper _route = new(
Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>());
private ScriptArtifactChangeSubscriber CreateSubscriber(IScriptArtifactChangeBus? bus) =>
new(_executor, NullLogger<ScriptArtifactChangeSubscriber>.Instance, bus);
new(_executor, _methodCache, NullLogger<ScriptArtifactChangeSubscriber>.Instance, bus);
private Task<InboundScriptResult> Run(ApiMethod m) => _executor.ExecuteAsync(
m, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
@@ -121,6 +122,34 @@ public class ScriptArtifactChangeSubscriberTests
Assert.Equal(0, _bus.SubscriberCount);
}
/// <summary>
/// WP2.6b: an ApiMethod change notification must also drop the resolved-row cache
/// entry (not just the compiled handler) — proving EndpointExtensions' next lookup
/// re-fetches instead of serving a stale cached row for the rest of the TTL window.
/// </summary>
[Fact]
public async Task ApiMethodPublish_InvalidatesResolvedMethodCache()
{
var subscriber = CreateSubscriber(_bus);
await subscriber.StartAsync(CancellationToken.None);
var fetchCount = 0;
Task<ApiMethod?> Fetch(CancellationToken _)
{
fetchCount++;
return Task.FromResult<ApiMethod?>(new ApiMethod("m", "return 1;") { Id = 1 });
}
await _methodCache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
await _methodCache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
Assert.Equal(1, fetchCount); // second call served from cache
_bus.Publish(ApiMethodChanged("m"));
await _methodCache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
Assert.Equal(2, fetchCount); // cache entry was dropped — re-fetched
}
[Fact]
public async Task NullBus_NoOps()
{
@@ -215,4 +215,99 @@ public class SiteRepositoryTests : IDisposable
// (arch-review 08 §1.3/#23) along with the vestigial SiteNotificationRepository —
// notification config is central-only and never lives on a site. The synthetic-ID
// stability guarantee is still exercised by ExternalSystemRepository_SyntheticId_IsStableAcrossRestart.
// ── WP2.6a: ExternalSystemDefinitionCache (arch-review misc — site external-system resolution) ──
/// <summary>
/// WP2.6a: two repository instances sharing one <see cref="ExternalSystemDefinitionCache"/>
/// (the production DI shape — one singleton cache, many scoped repository instances) must
/// see the SAME cached snapshot: a row written after the cache was already populated by
/// the first repository is invisible to the second until the cache is invalidated.
/// </summary>
[Fact]
public async Task ExternalSystemRepository_SharedCache_HitServesStaleSnapshotUntilInvalidated()
{
var storage = NewStorage();
await storage.InitializeAsync();
await storage.StoreExternalSystemAsync("Alpha", "https://alpha.test", "None", null, null);
var cache = new ExternalSystemDefinitionCache();
var repo1 = new SiteExternalSystemRepository(storage, cache);
var repo2 = new SiteExternalSystemRepository(storage, cache);
// repo1 populates the shared cache.
var initial = await repo1.GetAllExternalSystemsAsync();
Assert.Single(initial);
// A new system is written directly to storage — repo2, sharing the same
// cache, must NOT see it yet (cache hit serves the stale snapshot).
await storage.StoreExternalSystemAsync("Beta", "https://beta.test", "None", null, null);
var stillStale = await repo2.GetAllExternalSystemsAsync();
Assert.Single(stillStale);
// After invalidation, the next read reloads from storage and sees both rows.
cache.InvalidateAll();
var fresh = await repo2.GetAllExternalSystemsAsync();
Assert.Equal(2, fresh.Count);
}
/// <summary>
/// WP2.6a: the by-ID lookups (system and method) must resolve correctly out of the
/// cached snapshot — proving the O(1) id-index build in <c>LoadSnapshotAsync</c> is
/// wired correctly for both entity kinds, not just the by-name path already covered
/// by ExternalSystemGateway-011.
/// </summary>
[Fact]
public async Task ExternalSystemRepository_SharedCache_ByIdLookups_ResolveSystemAndMethod()
{
var storage = NewStorage();
await storage.InitializeAsync();
var methodDefs = "[{\"Name\":\"getData\",\"HttpMethod\":\"GET\",\"Path\":\"/data\"}]";
await storage.StoreExternalSystemAsync(
"WeatherApi", "https://api.example.com", "ApiKey", null, methodDefs);
var cache = new ExternalSystemDefinitionCache();
var repo = new SiteExternalSystemRepository(storage, cache);
var system = await repo.GetExternalSystemByNameAsync("WeatherApi");
Assert.NotNull(system);
var byId = await repo.GetExternalSystemByIdAsync(system!.Id);
Assert.NotNull(byId);
Assert.Equal("WeatherApi", byId!.Name);
var methods = await repo.GetMethodsByExternalSystemIdAsync(system.Id);
Assert.Single(methods);
var methodById = await repo.GetExternalSystemMethodByIdAsync(methods[0].Id);
Assert.NotNull(methodById);
Assert.Equal("getData", methodById!.Name);
Assert.Null(await repo.GetExternalSystemByIdAsync(-1));
Assert.Null(await repo.GetExternalSystemMethodByIdAsync(-1));
}
/// <summary>
/// WP2.6a: a repository constructed via the single-arg (no shared cache) constructor
/// gets its own private cache — a second such repository over the same storage must
/// see rows written after the first repository's cache was already populated,
/// preserving the pre-cache "always fresh" behavior for callers that opt out of
/// sharing.
/// </summary>
[Fact]
public async Task ExternalSystemRepository_PrivateCache_DoesNotShareAcrossInstances()
{
var storage = NewStorage();
await storage.InitializeAsync();
await storage.StoreExternalSystemAsync("Alpha", "https://alpha.test", "None", null, null);
var repo1 = new SiteExternalSystemRepository(storage);
Assert.Single(await repo1.GetAllExternalSystemsAsync());
await storage.StoreExternalSystemAsync("Beta", "https://beta.test", "None", null, null);
// A brand-new repository instance (its own private cache, unpopulated) sees both rows.
var repo2 = new SiteExternalSystemRepository(storage);
Assert.Equal(2, (await repo2.GetAllExternalSystemsAsync()).Count);
}
}
@@ -75,4 +75,13 @@ public class SiteRuntimeOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("StartupBatchDelayMs", result.FailureMessage);
}
[Fact]
public void ZeroAlarmPublishQueueCapacity_IsRejected()
{
var result = Validate(new SiteRuntimeOptions { AlarmPublishQueueCapacity = 0 });
Assert.True(result.Failed);
Assert.Contains("AlarmPublishQueueCapacity", result.FailureMessage);
}
}
@@ -0,0 +1,57 @@
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming;
/// <summary>
/// WP2.6d: the hosted reporter must lift <see cref="SiteStreamManager.AlarmPublishDroppedCount"/>
/// onto the site health report via <see cref="ISiteHealthCollector"/>. Uses the real
/// collector (mirrors <c>ScriptSchedulerStatsReporterTests</c> — NSubstitute is not
/// referenced by this test project). The queue's own drop-counting mechanism is verified
/// deterministically and separately by
/// <see cref="Streaming.SiteStreamManagerTests.CreateAlarmPublishQueue_OverCapacity_DropsOldestAndInvokesCallback"/>;
/// this test proves the reporter's poll-and-push wiring runs correctly.
/// </summary>
public class SiteStreamAlarmDropReporterTests : TestKit, IDisposable
{
void IDisposable.Dispose() => Shutdown();
[Fact]
public async Task Reporter_PushesAlarmDropCountToCollector()
{
var options = new SiteRuntimeOptions { StreamBufferSize = 100 };
var collector = new SiteHealthCollector();
var streamManager = new SiteStreamManager(options, NullLogger<SiteStreamManager>.Instance);
streamManager.Initialize(Sys);
using var reporter = new SiteStreamAlarmDropReporter(
collector, streamManager, NullLogger<SiteStreamAlarmDropReporter>.Instance,
pollInterval: TimeSpan.FromMilliseconds(50));
await reporter.StartAsync(CancellationToken.None);
try
{
// The immediate first probe (no drops yet) must reach the report as 0 —
// proving the reporter actually ran and pushed a value, not that the field
// simply defaulted.
await WaitUntilAsync(() =>
collector.CollectReport("site-1").SiteStreamAlarmDropCount == streamManager.AlarmPublishDroppedCount);
var report = collector.CollectReport("site-1");
Assert.Equal(streamManager.AlarmPublishDroppedCount, report.SiteStreamAlarmDropCount);
}
finally
{
await reporter.StopAsync(CancellationToken.None);
}
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
for (var i = 0; i < 100 && !condition(); i++)
await Task.Delay(50);
Assert.True(condition(), "condition not met within timeout");
}
}
@@ -159,4 +159,86 @@ public class SiteStreamManagerTests : TestKit, IDisposable
_streamManager.RemoveSubscriber(probe.Ref);
Assert.Equal(0, _streamManager.SubscriptionCount);
}
// ── WP2.6d: separate alarm publish path, drop counter, skip-at-zero-subscribers ──
/// <summary>
/// WP2.6d: alarm state changes now travel a dedicated publish source, isolated from
/// the (far higher-volume) attribute path — a burst of attribute events for OTHER
/// instances, interleaved with an alarm-only subscriber's events, must not cause any
/// alarm to be lost.
/// </summary>
[Fact]
public void PublishAlarmStateChanged_SurvivesConcurrentAttributeStorm_ForUnrelatedInstances()
{
var alarmProbe = CreateTestProbe();
_streamManager.SubscribeSiteAlarms(alarmProbe.Ref);
// A storm of attribute events for a DIFFERENT instance — none of which the
// alarm subscriber is even listening to — interleaved with alarm events. Before
// WP2.6d these shared one upstream buffer; now they are fully separate sources.
for (var i = 0; i < 500; i++)
{
_streamManager.PublishAttributeValueChanged(new AttributeValueChanged(
"NoisyPump", "Temperature", "Temperature", i.ToString(), "Good", DateTimeOffset.UtcNow));
}
_streamManager.PublishAlarmStateChanged(new AlarmStateChanged(
"Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow));
var received = alarmProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(3));
Assert.Equal("Pump1", received.InstanceUniqueName);
}
/// <summary>
/// WP2.6d: with zero subscribers of any kind, PublishAlarmStateChanged/
/// PublishAttributeValueChanged must be no-ops — no exception, and (for alarms) the
/// event never reaches the bounded hand-off queue, so
/// <see cref="SiteStreamManager.AlarmPublishDroppedCount"/> stays at zero rather than
/// counting events nobody could ever have received anyway.
/// </summary>
[Fact]
public void Publish_WithNoSubscribers_IsNoOp_AndDoesNotCountAsDropped()
{
_streamManager.PublishAlarmStateChanged(new AlarmStateChanged(
"Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow));
_streamManager.PublishAttributeValueChanged(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "1", "Good", DateTimeOffset.UtcNow));
Assert.Equal(0, _streamManager.AlarmPublishDroppedCount);
// Publishing resumes working normally once a subscriber exists.
var probe = CreateTestProbe();
_streamManager.SubscribeSiteAlarms(probe.Ref);
_streamManager.PublishAlarmStateChanged(new AlarmStateChanged(
"Pump1", "HighTemp", AlarmState.Active, 2, DateTimeOffset.UtcNow));
probe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(3));
}
/// <summary>
/// WP2.6d: once the bounded alarm hand-off queue (the exact factory
/// <see cref="SiteStreamManager.Initialize"/> wires into the drop-count callback) is
/// full, a further write evicts the oldest queued item and fires the drop callback.
/// Exercised directly against the queue factory — deterministic, no actor
/// system/async pump race involved (see the factory's own doc comment).
/// </summary>
[Fact]
public void CreateAlarmPublishQueue_OverCapacity_DropsOldestAndInvokesCallback()
{
var dropped = 0;
var queue = SiteStreamManager.CreateAlarmPublishQueue(capacity: 2, () => Interlocked.Increment(ref dropped));
// Nothing reads from this queue, so all five writes stay purely upstream —
// capacity 2 means the 3rd/4th/5th writes must each evict the oldest.
for (var i = 0; i < 5; i++)
{
var wrote = queue.Writer.TryWrite(new AlarmStateChanged(
"Pump1", "HighTemp", AlarmState.Active, i, DateTimeOffset.UtcNow));
Assert.True(wrote); // DropOldest TryWrite always succeeds once capacity > 0
}
Assert.Equal(3, dropped);
Assert.Equal(2, queue.Reader.Count);
}
}
@@ -83,4 +83,16 @@ public class StoreAndForwardOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("SweepTargetParallelism", result.FailureMessage);
}
// ── WP2.6c: bounded observer queue capacity ──
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Validate_NonPositiveObserverQueueCapacity_Fails(int capacity)
{
var result = Validate(new StoreAndForwardOptions { ObserverQueueCapacity = capacity });
Assert.True(result.Failed);
Assert.Contains("ObserverQueueCapacity", result.FailureMessage);
}
}
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.TestSupport;
@@ -885,4 +887,82 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
release.TrySetResult();
await stop.WaitAsync(TimeSpan.FromSeconds(5)); // drains the real sweep promptly once released
}
// ── WP2.6c: bounded, DropOldest observer queue + drop counter ──
private sealed class BlockingObserver : ICachedCallLifecycleObserver
{
private readonly TaskCompletionSource _gate;
public BlockingObserver(TaskCompletionSource gate) => _gate = gate;
public async Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
=> await _gate.Task;
}
/// <summary>
/// WP2.6c: the cached-call audit-observer queue is bounded — once the single-reader
/// pump is stuck awaiting a slow/stuck observer, further posted notifications must
/// evict the oldest queued one (DropOldest) instead of growing without bound, and
/// every eviction must increment <see cref="StoreAndForwardService.ObserverQueueDroppedCount"/>.
/// </summary>
[Fact]
public async Task ObserverQueue_BoundedCapacity_DropsOldestAndCountsDrops()
{
var gate = new TaskCompletionSource();
var observer = new BlockingObserver(gate);
var localDb = TestLocalDb.CreateTemp("ObsQueueBound");
var storage = new StoreAndForwardStorage(localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
var service = new StoreAndForwardService(
storage,
new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 5,
RetryTimerInterval = TimeSpan.FromHours(1), // timer never fires in-test
ObserverQueueCapacity = 2,
},
NullLogger<StoreAndForwardService>.Instance,
cachedCallObserver: observer,
siteId: "site-77");
await service.StartAsync();
try
{
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("transient"));
// Enqueue more messages than the bounded capacity (2) — the pump dequeues
// the first notification and blocks on the observer gate, so every
// subsequent notification posted during this sweep queues (and, past
// capacity, evicts the oldest still-queued one) rather than being
// processed.
for (var i = 0; i < 6; i++)
{
await service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, $"t{i}", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero,
messageId: TrackedOperationId.New().ToString());
}
await service.RetryPendingMessagesAsync();
// Give the bounded channel a moment to have absorbed/evicted every post
// (the pump itself stays blocked on the gate throughout).
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && service.ObserverQueueDroppedCount == 0)
await Task.Delay(10);
Assert.True(service.ObserverQueueDroppedCount > 0,
"expected at least one notification to be dropped once the bounded queue filled");
}
finally
{
gate.TrySetResult();
await service.StopAsync();
var path = localDb.Path;
localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
}
}