235 lines
7.9 KiB
C#
235 lines
7.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// WP-11: Tests for async replication to standby.
|
|
/// </summary>
|
|
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<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() => _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<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)));
|
|
}
|
|
}
|