using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// /// WP-11: Tests for async replication to standby. /// public class ReplicationServiceTests : IAsyncLifetime, IDisposable { private readonly SqliteConnection _keepAlive; private readonly StoreAndForwardStorage _storage; private readonly ReplicationService _replicationService; public ReplicationServiceTests() { var dbName = $"RepTests_{Guid.NewGuid():N}"; var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; _keepAlive = new SqliteConnection(connStr); _keepAlive.Open(); _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); var options = new StoreAndForwardOptions { ReplicationEnabled = true }; _replicationService = new ReplicationService( options, NullLogger.Instance); } public async Task InitializeAsync() => await _storage.InitializeAsync(); public Task DisposeAsync() => Task.CompletedTask; public void Dispose() => _keepAlive.Dispose(); [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.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(); 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 }; } /// Minimal in-memory logger that records level + rendered message. private sealed class CapturingLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add((logLevel, formatter(state, exception))); } }