Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationServiceTests.cs
T
Joseph Doherty f2efeb37b7 refactor(sf,site): both stores take ILocalDb instead of a connection string
Tasks 5 and 6 of the Phase 2 plan, committed together because their test
fallout is entangled — several fixtures construct both stores.

StoreAndForwardStorage and SiteStorageService now take ILocalDb. Connections
come from ILocalDb.CreateConnection(), which hands out an already-open,
pragma-configured connection carrying the zb_hlc_next() UDF the capture triggers
call; a raw connection would lack the UDF and every write to a replicated table
would fail closed. Deleted with the connection strings: S&F's
EnsureDatabaseDirectoryExists and its per-open busy_timeout pragma, and the site
service's BusyTimeoutFloorSeconds normalization — LocalDb owns all of it now.

DI: AddSiteRuntime's string overload is gone (nothing left to supply), so the
Host calls the no-arg form. ScadaBridge:Database:SiteDbPath and
StoreAndForwardOptions.SqliteDbPath survive only as the migrator's source
locations in Tasks 8/9.

Two things the plan did not anticipate, both worth reading:

1. FOUND A REAL LATENT DEFECT, from Phase 1, now fixed. The plan assumed
   directory creation simply moved to LocalDb along with file ownership. It did
   not: the LocalDb library never creates the parent directory, and
   SqliteLocalDb opens the file eagerly in its constructor — so a missing
   directory is a hard boot failure ("SQLite Error 14: unable to open database
   file"), not a degraded start. The default site config points at the RELATIVE
   path ./data/site-localdb.db, so any site node without a pre-existing data/
   directory fails to boot. The docker rig escapes only because its volume mount
   happens to create /app/data — a coincidence that would have hidden this until
   a bare-metal or fresh deployment. This has been latent since Phase 1 made
   LocalDb:Path required; deleting S&F's EnsureDatabaseDirectoryExists here
   would have widened it. Re-established the guarantee at the layer that now
   owns the path (SiteLocalDbDirectory.Ensure, called before AddZbLocalDb) and
   pinned it with SiteLocalDbDirectoryTests. Non-vacuity is not assumed: two
   tests written against the wrong assumption failed with exactly this
   SQLite Error 14 before the fix existed.

2. Test fallout was ~7x the plan's estimate. The plan named "fixtures" in one
   project; the constructor change actually reaches 40 files across 7 test
   projects, and most used Mode=Memory;Cache=Shared — which LocalDb has no
   equivalent for, so every one had to move to a real temp file. Rather than
   copy the Phase 1 TestLocalDb fixture into 7 projects, added a shared
   tests/ZB.MOM.WW.ScadaBridge.TestSupport library (not a test project) so the
   WAL-sidecar cleanup and the "real, not stubbed" rationale live in one place.

Retargeted rather than deleted, in both directions: the S&F WAL test now asserts
against the LocalDb-backed store (WAL genuinely is LocalDb's job), while the
directory-creation test moved to Host.Tests (that guarantee is NOT LocalDb's).
SiteStorageServiceTests.Initialize_EnablesWalJournalMode got the same treatment.
DeploymentManagerMediumFindingsTests induced a persistence failure via an
unopenable path, which no longer reaches the assertion since the fixture now
throws first; it induces the same failure shape via an uninitialized store.

Verified: full solution build 0 warnings; SiteRuntime 532, Host 318,
AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97,
StoreAndForward 153 — 1597 passed, 0 failed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 03:05:45 -04:00

237 lines
7.9 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-11: Tests for async replication to standby.
/// </summary>
public class ReplicationServiceTests : IAsyncLifetime, IDisposable
{
private readonly TestLocalDb _localDb;
private readonly StoreAndForwardStorage _storage;
private readonly ReplicationService _replicationService;
public ReplicationServiceTests()
{
_localDb = TestLocalDb.CreateTemp("RepTests");
_storage = new StoreAndForwardStorage(_localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
var options = new StoreAndForwardOptions { ReplicationEnabled = true };
_replicationService = new ReplicationService(
options, NullLogger<ReplicationService>.Instance);
}
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 void ReplicateEnqueue_NoHandler_DoesNotThrow()
{
var msg = CreateMessage("rep1");
_replicationService.ReplicateEnqueue(msg);
}
[Fact]
public async Task ReplicateEnqueue_WithHandler_ForwardsOperation()
{
ReplicationOperation? captured = null;
_replicationService.SetReplicationHandler(op =>
{
captured = op;
return Task.CompletedTask;
});
var msg = CreateMessage("rep2");
_replicationService.ReplicateEnqueue(msg);
await Task.Delay(200);
Assert.NotNull(captured);
Assert.Equal(ReplicationOperationType.Add, captured!.OperationType);
Assert.Equal("rep2", captured.MessageId);
}
[Fact]
public async Task ReplicateRemove_WithHandler_ForwardsRemoveOperation()
{
ReplicationOperation? captured = null;
_replicationService.SetReplicationHandler(op =>
{
captured = op;
return Task.CompletedTask;
});
_replicationService.ReplicateRemove("rep3");
await Task.Delay(200);
Assert.NotNull(captured);
Assert.Equal(ReplicationOperationType.Remove, captured!.OperationType);
Assert.Equal("rep3", captured.MessageId);
}
[Fact]
public async Task ReplicatePark_WithHandler_ForwardsParkOperation()
{
ReplicationOperation? captured = null;
_replicationService.SetReplicationHandler(op =>
{
captured = op;
return Task.CompletedTask;
});
var msg = CreateMessage("rep4");
_replicationService.ReplicatePark(msg);
await Task.Delay(200);
Assert.NotNull(captured);
Assert.Equal(ReplicationOperationType.Park, captured!.OperationType);
}
[Fact]
public async Task ApplyReplicatedOperationAsync_Add_EnqueuesMessage()
{
var msg = CreateMessage("apply1");
var operation = new ReplicationOperation(ReplicationOperationType.Add, "apply1", msg);
await _replicationService.ApplyReplicatedOperationAsync(operation, _storage);
var retrieved = await _storage.GetMessageByIdAsync("apply1");
Assert.NotNull(retrieved);
}
[Fact]
public async Task ApplyReplicatedOperationAsync_Remove_DeletesMessage()
{
var msg = CreateMessage("apply2");
await _storage.EnqueueAsync(msg);
var operation = new ReplicationOperation(ReplicationOperationType.Remove, "apply2", null);
await _replicationService.ApplyReplicatedOperationAsync(operation, _storage);
var retrieved = await _storage.GetMessageByIdAsync("apply2");
Assert.Null(retrieved);
}
[Fact]
public async Task ApplyReplicatedOperationAsync_Park_UpdatesStatus()
{
var msg = CreateMessage("apply3");
await _storage.EnqueueAsync(msg);
var operation = new ReplicationOperation(ReplicationOperationType.Park, "apply3", msg);
await _replicationService.ApplyReplicatedOperationAsync(operation, _storage);
var retrieved = await _storage.GetMessageByIdAsync("apply3");
Assert.NotNull(retrieved);
Assert.Equal(StoreAndForwardMessageStatus.Parked, retrieved!.Status);
}
[Fact]
public void ReplicateEnqueue_WhenReplicationDisabled_DoesNothing()
{
var options = new StoreAndForwardOptions { ReplicationEnabled = false };
var service = new ReplicationService(options, NullLogger<ReplicationService>.Instance);
bool handlerCalled = false;
service.SetReplicationHandler(_ => { handlerCalled = true; return Task.CompletedTask; });
service.ReplicateEnqueue(CreateMessage("disabled1"));
Assert.False(handlerCalled);
}
[Fact]
public async Task ReplicateEnqueue_HandlerThrows_DoesNotPropagateException()
{
_replicationService.SetReplicationHandler(_ =>
throw new InvalidOperationException("standby down"));
_replicationService.ReplicateEnqueue(CreateMessage("err1"));
await Task.Delay(200);
// No exception -- fire-and-forget, best-effort
}
// ── Task 10 (arch review 02): ordered, observable replication dispatch ──
[Fact]
public void ReplicationOperations_AreDispatchedInIssueOrder()
{
var seen = new List<(ReplicationOperationType, string)>();
_replicationService.SetReplicationHandler(op =>
{
seen.Add((op.OperationType, op.MessageId));
return Task.CompletedTask;
});
for (var i = 0; i < 200; i++)
{
_replicationService.ReplicateEnqueue(CreateMessage($"m{i}"));
_replicationService.ReplicateRemove($"m{i}");
}
// Inline dispatch: by the time the calls return, every op was handed to the
// handler, Add strictly before Remove per id. Pre-fix (Task.Run) this was
// racy in both count and order.
Assert.Equal(400, seen.Count);
for (var i = 0; i < 200; i++)
{
Assert.Equal((ReplicationOperationType.Add, $"m{i}"), seen[2 * i]);
Assert.Equal((ReplicationOperationType.Remove, $"m{i}"), seen[2 * i + 1]);
}
}
[Fact]
public void ReplicationHandlerThrow_IsSwallowed_AndLoggedAtWarning()
{
var logger = new CapturingLogger<ReplicationService>();
var service = new ReplicationService(new StoreAndForwardOptions(), logger);
service.SetReplicationHandler(_ => throw new InvalidOperationException("peer gone"));
service.ReplicateRemove("m1"); // must not throw
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
}
private static StoreAndForwardMessage CreateMessage(string id)
{
return new StoreAndForwardMessage
{
Id = id,
Category = StoreAndForwardCategory.ExternalSystem,
Target = "target",
PayloadJson = "{}",
RetryCount = 0,
MaxRetries = 50,
RetryIntervalMs = 30000,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending
};
}
/// <summary>Minimal in-memory logger that records level + rendered message.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
}
}