037798b367
Tasks 14, 15 and 16, landed as ONE commit. PLAN DEFECT: these three tasks cannot compile separately. SiteReplicationActor takes a ReplicationService and calls ReplaceAllAsync (Task 14 deletes both); DeploymentManagerActor Tells message types declared in ReplicationMessages.cs (Task 15 deletes it); AkkaHostedService constructs the actor (Task 16). Any ordering leaves a broken intermediate. Combining them also strengthens the invariant Task 14 already stated for itself — the two mechanisms never both run, and never neither. Registered 8 tables in SiteLocalDbSetup.OnReady: sf_messages plus the 7 site config tables. notification_lists and smtp_configurations are deliberately NOT registered — permanently empty by design, so registering them would open a standing replication channel whose only historical payload was plaintext SMTP passwords. Migrate stays the LAST call in OnReady, after all registrations, so migrated rows enter the oplog through live capture triggers. Deleted: SiteReplicationActor, ReplicationMessages.cs, ReplicationService, StoreAndForwardStorage.ReplaceAllAsync, and 6 test files. ReplaceAllAsync is not merely unused but unsafe to keep: a mass DELETE on a now-replicated table would be captured and shipped to the peer. Kept ActiveNodeEvaluator (delivery gate + heartbeat still need it) with its doc corrected, and activeNodeCheck in AkkaHostedService (SiteCommunicationActor). The positional-argument hazard the plan flagged was real: removing DeploymentManagerActor's optional IActorRef? replicationActor shifted 6 trailing optionals, and 4 test call sites bound the wrong arguments with no compile error at some positions. Converted them to named arguments where possible — Props.Create builds an expression tree, which rejects out-of-position named args, so the rest are padded positionally with a comment saying why. The Task 7 'not yet registered' test was INVERTED rather than deleted, and is exact in both directions: too few means a table silently stops replicating, too many means the SMTP tables leak. Added a separate security-named test for those two, and a composite-PK test (LWW keys on the full PK, so a truncated key set would collapse distinct rows). The convergence suites now get their registrations from the real OnReady — their temporary harness registration is deleted, so they prove the cutover rather than agreeing with themselves. Verified: build 0 warnings; SiteRuntime 512, StoreAndForward 130, Host 330, AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97, LocalDb integration 16 — all pass, 0 failures. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
171 lines
7.2 KiB
C#
171 lines
7.2 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
|
|
|
|
/// <summary>
|
|
/// M1.7: the StoreAndForwardService emits site operational events for its own
|
|
/// buffer/park activity — <c>store_and_forward</c> for cached-call categories
|
|
/// (ExternalSystem / CachedDbWrite) and <c>notification</c> for the site's
|
|
/// notification forward-to-central path. Emission rides the existing
|
|
/// <c>OnActivity</c> hook and is best-effort (a failing logger never affects
|
|
/// delivery bookkeeping).
|
|
/// </summary>
|
|
public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
|
|
{
|
|
private sealed record Entry(string EventType, string Severity, string Source, string Message);
|
|
|
|
private sealed class FakeSiteEventLogger : ISiteEventLogger
|
|
{
|
|
private readonly ConcurrentQueue<Entry> _entries = new();
|
|
public IReadOnlyList<Entry> Entries => _entries.ToArray();
|
|
public IReadOnlyList<Entry> OfType(string t) => _entries.Where(e => e.EventType == t).ToArray();
|
|
|
|
public Task LogEventAsync(string eventType, string severity, string? instanceId,
|
|
string source, string message, string? details = null)
|
|
{
|
|
_entries.Enqueue(new Entry(eventType, severity, source, message));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public long FailedWriteCount => 0;
|
|
}
|
|
|
|
private readonly TestLocalDb _localDb;
|
|
private readonly StoreAndForwardStorage _storage;
|
|
private readonly StoreAndForwardOptions _options;
|
|
private readonly FakeSiteEventLogger _siteLog = new();
|
|
private readonly StoreAndForwardService _service;
|
|
|
|
public StoreAndForwardSiteEventTests()
|
|
{
|
|
_localDb = TestLocalDb.CreateTemp("SiteEvt");
|
|
|
|
_storage = new StoreAndForwardStorage(_localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
|
|
_options = new StoreAndForwardOptions
|
|
{
|
|
DefaultRetryInterval = TimeSpan.Zero,
|
|
DefaultMaxRetries = 1,
|
|
RetryTimerInterval = TimeSpan.FromMinutes(10)
|
|
};
|
|
|
|
_service = new StoreAndForwardService(
|
|
_storage, _options, NullLogger<StoreAndForwardService>.Instance,
|
|
cachedCallObserver: null, siteId: "site-a",
|
|
siteEventLogger: _siteLog);
|
|
}
|
|
|
|
public async Task InitializeAsync() => await _storage.InitializeAsync();
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
public void Dispose()
|
|
{
|
|
var path = _localDb.Path;
|
|
_localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(path);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BufferForRetry_ExternalSystem_EmitsStoreAndForwardSiteEvent()
|
|
{
|
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ => throw new HttpRequestException("transient"));
|
|
|
|
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api.example.com", """{}""", "Pump1");
|
|
|
|
var rows = _siteLog.OfType("store_and_forward");
|
|
Assert.Contains(rows, r => r.Severity == "Warning" &&
|
|
r.Source == "StoreAndForwardService" &&
|
|
r.Message.Contains("queued", StringComparison.OrdinalIgnoreCase));
|
|
// The cached-call categories must NOT surface as notification events.
|
|
Assert.Empty(_siteLog.OfType("notification"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForwardFailure_Notification_EmitsNotificationSiteEvent()
|
|
{
|
|
// The site's notification role is forward-to-central. When the immediate
|
|
// forward to central throws (central unreachable), the notification is
|
|
// buffered for retry — a forward FAILURE, which the spec says to log as a
|
|
// `notification` site event (filling the in-transit blind spot).
|
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
|
|
_ => throw new HttpRequestException("central unreachable"));
|
|
|
|
await _service.EnqueueAsync(StoreAndForwardCategory.Notification, "list-a", """{}""", "Pump1");
|
|
|
|
var rows = _siteLog.OfType("notification");
|
|
Assert.Contains(rows, r => r.Severity == "Warning" &&
|
|
r.Source == "StoreAndForwardService" &&
|
|
r.Message.Contains("queued", StringComparison.OrdinalIgnoreCase));
|
|
// A notification forward-failure is not a store_and_forward (cached-call) event.
|
|
Assert.Empty(_siteLog.OfType("store_and_forward"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RoutineEnqueue_Notification_DoesNotEmitSiteEvent()
|
|
{
|
|
// Spec: routine enqueue / forward-success on the notification path are
|
|
// deliberately NOT logged — central's Notifications table is the audit
|
|
// record of record. A successful immediate forward emits no site event.
|
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
|
|
_ => Task.FromResult(true));
|
|
|
|
await _service.EnqueueAsync(StoreAndForwardCategory.Notification, "list-a", """{}""", "Pump1");
|
|
|
|
Assert.Empty(_siteLog.OfType("notification"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Park_Notification_EmitsErrorNotificationSiteEvent()
|
|
{
|
|
// A long-buffered notification that exhausts retries is parked — the spec
|
|
// logs this as a `notification` event (Error severity).
|
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
|
|
_ => throw new HttpRequestException("central unreachable"));
|
|
|
|
await _service.EnqueueAsync(
|
|
StoreAndForwardCategory.Notification, "list-a", """{}""", "Pump1",
|
|
attemptImmediateDelivery: false, maxRetries: 1);
|
|
|
|
await _service.RetryPendingMessagesAsync();
|
|
|
|
var rows = _siteLog.OfType("notification");
|
|
Assert.Contains(rows, r => r.Severity == "Error" &&
|
|
r.Message.Contains("parked", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Park_ExternalSystem_EmitsErrorStoreAndForwardSiteEvent()
|
|
{
|
|
// MaxRetries = 1 → the first sweep retry parks the message.
|
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ => throw new HttpRequestException("transient"));
|
|
|
|
await _service.EnqueueAsync(
|
|
StoreAndForwardCategory.ExternalSystem, "api.example.com", """{}""", "Pump1",
|
|
attemptImmediateDelivery: false, maxRetries: 1);
|
|
|
|
await _service.RetryPendingMessagesAsync();
|
|
|
|
var rows = _siteLog.OfType("store_and_forward");
|
|
Assert.Contains(rows, r => r.Severity == "Error" &&
|
|
r.Message.Contains("parked", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeliveredImmediately_DoesNotEmitSiteEvent()
|
|
{
|
|
// A successful immediate delivery is the normal hot path — it is not a
|
|
// store-and-forward buffering event, so no operational event is logged.
|
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ => Task.FromResult(true));
|
|
|
|
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api", """{}""", "Pump1");
|
|
|
|
Assert.Empty(_siteLog.OfType("store_and_forward"));
|
|
Assert.Empty(_siteLog.OfType("notification"));
|
|
}
|
|
}
|