Deployment Manager (WP-1–8, WP-16): - DeploymentService: full pipeline (flatten→validate→send→track→audit) - OperationLockManager: per-instance concurrency control - StateTransitionValidator: Enabled/Disabled/NotDeployed transition matrix - ArtifactDeploymentService: broadcast to all sites with per-site results - Deployment identity (GUID + revision hash), idempotency, staleness detection - Instance lifecycle commands (disable/enable/delete) with deduplication Store-and-Forward (WP-9–15): - StoreAndForwardStorage: SQLite persistence, 3 categories, no max buffer - StoreAndForwardService: fixed-interval retry, transient-only buffering, parking - ReplicationService: async best-effort to standby (fire-and-forget) - Parked message management (query/retry/discard from central) - Messages survive instance deletion, S&F drains on disable 620 tests pass (+79 new), zero warnings.
225 lines
8.3 KiB
C#
225 lines
8.3 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
|
|
namespace ScadaLink.StoreAndForward.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-9: Tests for SQLite persistence layer.
|
|
/// Uses in-memory SQLite with a kept-alive connection for test isolation.
|
|
/// </summary>
|
|
public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
|
|
{
|
|
private readonly SqliteConnection _keepAlive;
|
|
private readonly StoreAndForwardStorage _storage;
|
|
private readonly string _dbName;
|
|
|
|
public StoreAndForwardStorageTests()
|
|
{
|
|
_dbName = $"StorageTests_{Guid.NewGuid():N}";
|
|
var connStr = $"Data Source={_dbName};Mode=Memory;Cache=Shared";
|
|
// Keep one connection alive so the in-memory DB persists
|
|
_keepAlive = new SqliteConnection(connStr);
|
|
_keepAlive.Open();
|
|
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
|
|
}
|
|
|
|
public async Task InitializeAsync() => await _storage.InitializeAsync();
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
public void Dispose()
|
|
{
|
|
_keepAlive.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnqueueAsync_StoresMessage()
|
|
{
|
|
var message = CreateMessage("msg1", StoreAndForwardCategory.ExternalSystem);
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("msg1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Equal("msg1", retrieved!.Id);
|
|
Assert.Equal(StoreAndForwardCategory.ExternalSystem, retrieved.Category);
|
|
Assert.Equal("target1", retrieved.Target);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnqueueAsync_AllCategories()
|
|
{
|
|
await _storage.EnqueueAsync(CreateMessage("es1", StoreAndForwardCategory.ExternalSystem));
|
|
await _storage.EnqueueAsync(CreateMessage("n1", StoreAndForwardCategory.Notification));
|
|
await _storage.EnqueueAsync(CreateMessage("db1", StoreAndForwardCategory.CachedDbWrite));
|
|
|
|
var es = await _storage.GetMessageByIdAsync("es1");
|
|
var n = await _storage.GetMessageByIdAsync("n1");
|
|
var db = await _storage.GetMessageByIdAsync("db1");
|
|
|
|
Assert.Equal(StoreAndForwardCategory.ExternalSystem, es!.Category);
|
|
Assert.Equal(StoreAndForwardCategory.Notification, n!.Category);
|
|
Assert.Equal(StoreAndForwardCategory.CachedDbWrite, db!.Category);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemoveMessageAsync_RemovesSuccessfully()
|
|
{
|
|
await _storage.EnqueueAsync(CreateMessage("rm1", StoreAndForwardCategory.ExternalSystem));
|
|
await _storage.RemoveMessageAsync("rm1");
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("rm1");
|
|
Assert.Null(retrieved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateMessageAsync_UpdatesFields()
|
|
{
|
|
var message = CreateMessage("upd1", StoreAndForwardCategory.ExternalSystem);
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
message.RetryCount = 5;
|
|
message.LastAttemptAt = DateTimeOffset.UtcNow;
|
|
message.Status = StoreAndForwardMessageStatus.Parked;
|
|
message.LastError = "Connection refused";
|
|
await _storage.UpdateMessageAsync(message);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("upd1");
|
|
Assert.Equal(5, retrieved!.RetryCount);
|
|
Assert.Equal(StoreAndForwardMessageStatus.Parked, retrieved.Status);
|
|
Assert.Equal("Connection refused", retrieved.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMessagesForRetryAsync_ReturnsOnlyPendingMessages()
|
|
{
|
|
var pending = CreateMessage("pend1", StoreAndForwardCategory.ExternalSystem);
|
|
pending.Status = StoreAndForwardMessageStatus.Pending;
|
|
await _storage.EnqueueAsync(pending);
|
|
|
|
var parked = CreateMessage("park1", StoreAndForwardCategory.ExternalSystem);
|
|
parked.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(parked);
|
|
await _storage.UpdateMessageAsync(parked);
|
|
|
|
var forRetry = await _storage.GetMessagesForRetryAsync();
|
|
Assert.All(forRetry, m => Assert.Equal(StoreAndForwardMessageStatus.Pending, m.Status));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetParkedMessagesAsync_ReturnsParkedOnly()
|
|
{
|
|
var msg = CreateMessage("prk1", StoreAndForwardCategory.Notification);
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
|
|
var (messages, total) = await _storage.GetParkedMessagesAsync();
|
|
Assert.True(total > 0);
|
|
Assert.All(messages, m => Assert.Equal(StoreAndForwardMessageStatus.Parked, m.Status));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryParkedMessageAsync_MovesToPending()
|
|
{
|
|
var msg = CreateMessage("retry1", StoreAndForwardCategory.ExternalSystem);
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
msg.RetryCount = 10;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
|
|
var success = await _storage.RetryParkedMessageAsync("retry1");
|
|
Assert.True(success);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("retry1");
|
|
Assert.Equal(StoreAndForwardMessageStatus.Pending, retrieved!.Status);
|
|
Assert.Equal(0, retrieved.RetryCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardParkedMessageAsync_RemovesMessage()
|
|
{
|
|
var msg = CreateMessage("disc1", StoreAndForwardCategory.ExternalSystem);
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
|
|
var success = await _storage.DiscardParkedMessageAsync("disc1");
|
|
Assert.True(success);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("disc1");
|
|
Assert.Null(retrieved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetBufferDepthByCategoryAsync_ReturnsCorrectCounts()
|
|
{
|
|
await _storage.EnqueueAsync(CreateMessage("bd1", StoreAndForwardCategory.ExternalSystem));
|
|
await _storage.EnqueueAsync(CreateMessage("bd2", StoreAndForwardCategory.ExternalSystem));
|
|
await _storage.EnqueueAsync(CreateMessage("bd3", StoreAndForwardCategory.Notification));
|
|
|
|
var depth = await _storage.GetBufferDepthByCategoryAsync();
|
|
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.ExternalSystem) >= 2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMessageCountByOriginInstanceAsync_ReturnsCount()
|
|
{
|
|
var msg1 = CreateMessage("oi1", StoreAndForwardCategory.ExternalSystem);
|
|
msg1.OriginInstanceName = "Pump1";
|
|
await _storage.EnqueueAsync(msg1);
|
|
|
|
var msg2 = CreateMessage("oi2", StoreAndForwardCategory.Notification);
|
|
msg2.OriginInstanceName = "Pump1";
|
|
await _storage.EnqueueAsync(msg2);
|
|
|
|
var count = await _storage.GetMessageCountByOriginInstanceAsync("Pump1");
|
|
Assert.Equal(2, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetParkedMessagesAsync_Pagination()
|
|
{
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
var msg = CreateMessage($"page{i}", StoreAndForwardCategory.ExternalSystem);
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
}
|
|
|
|
var (page1, total) = await _storage.GetParkedMessagesAsync(pageNumber: 1, pageSize: 2);
|
|
Assert.Equal(2, page1.Count);
|
|
Assert.True(total >= 5);
|
|
|
|
var (page2, _) = await _storage.GetParkedMessagesAsync(pageNumber: 2, pageSize: 2);
|
|
Assert.Equal(2, page2.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMessageCountByStatusAsync_ReturnsAccurateCount()
|
|
{
|
|
var msg = CreateMessage("cnt1", StoreAndForwardCategory.ExternalSystem);
|
|
await _storage.EnqueueAsync(msg);
|
|
|
|
var count = await _storage.GetMessageCountByStatusAsync(StoreAndForwardMessageStatus.Pending);
|
|
Assert.True(count >= 1);
|
|
}
|
|
|
|
private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category)
|
|
{
|
|
return new StoreAndForwardMessage
|
|
{
|
|
Id = id,
|
|
Category = category,
|
|
Target = "target1",
|
|
PayloadJson = """{"method":"Test","args":{}}""",
|
|
RetryCount = 0,
|
|
MaxRetries = 50,
|
|
RetryIntervalMs = 30000,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
Status = StoreAndForwardMessageStatus.Pending
|
|
};
|
|
}
|
|
}
|