Phase 3C: Deployment pipeline & Store-and-Forward engine

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.
This commit is contained in:
Joseph Doherty
2026-03-16 21:27:18 -04:00
parent b75bf52fb4
commit 6ea38faa6f
40 changed files with 3289 additions and 29 deletions

View File

@@ -0,0 +1,181 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.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
}
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
};
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -21,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,37 @@
namespace ScadaLink.StoreAndForward.Tests;
/// <summary>
/// WP-9: Tests for StoreAndForwardOptions defaults and configuration.
/// </summary>
public class StoreAndForwardOptionsTests
{
[Fact]
public void DefaultOptions_HasReasonableDefaults()
{
var options = new StoreAndForwardOptions();
Assert.Equal("./data/store-and-forward.db", options.SqliteDbPath);
Assert.True(options.ReplicationEnabled);
Assert.Equal(TimeSpan.FromSeconds(30), options.DefaultRetryInterval);
Assert.Equal(50, options.DefaultMaxRetries);
Assert.Equal(TimeSpan.FromSeconds(10), options.RetryTimerInterval);
}
[Fact]
public void Options_CanBeCustomized()
{
var options = new StoreAndForwardOptions
{
SqliteDbPath = "/custom/path.db",
ReplicationEnabled = false,
DefaultRetryInterval = TimeSpan.FromMinutes(5),
DefaultMaxRetries = 100,
RetryTimerInterval = TimeSpan.FromSeconds(30)
};
Assert.Equal("/custom/path.db", options.SqliteDbPath);
Assert.False(options.ReplicationEnabled);
Assert.Equal(TimeSpan.FromMinutes(5), options.DefaultRetryInterval);
Assert.Equal(100, options.DefaultMaxRetries);
}
}

View File

@@ -0,0 +1,313 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.StoreAndForward.Tests;
/// <summary>
/// WP-10/12/13/14: Tests for the StoreAndForwardService retry engine and management.
/// </summary>
public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
private readonly StoreAndForwardOptions _options;
public StoreAndForwardServiceTests()
{
var dbName = $"SvcTests_{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);
_options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10)
};
_service = new StoreAndForwardService(
_storage, _options, NullLogger<StoreAndForwardService>.Instance);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose() => _keepAlive.Dispose();
// ── WP-10: Immediate delivery ──
[Fact]
public async Task EnqueueAsync_ImmediateDeliverySuccess_ReturnsAcceptedNotBuffered()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api.example.com",
"""{"method":"Test"}""", "Pump1");
Assert.True(result.Accepted);
Assert.False(result.WasBuffered);
}
[Fact]
public async Task EnqueueAsync_PermanentFailure_ReturnsNotAccepted()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(false));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api.example.com",
"""{"method":"Test"}""");
Assert.False(result.Accepted);
Assert.False(result.WasBuffered);
}
[Fact]
public async Task EnqueueAsync_TransientFailure_BuffersForRetry()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("Connection refused"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api.example.com",
"""{"method":"Test"}""", "Pump1");
Assert.True(result.Accepted);
Assert.True(result.WasBuffered);
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
Assert.Equal(1, msg.RetryCount);
}
[Fact]
public async Task EnqueueAsync_NoHandler_BuffersForLater()
{
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.Notification, "alerts@company.com",
"""{"subject":"Alert"}""");
Assert.True(result.Accepted);
Assert.True(result.WasBuffered);
}
// ── WP-10: Retry engine ──
[Fact]
public async Task RetryPendingMessagesAsync_SuccessfulRetry_RemovesMessage()
{
int callCount = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ =>
{
callCount++;
if (callCount == 1) throw new HttpRequestException("fail");
return Task.FromResult(true);
});
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
Assert.True(result.WasBuffered);
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Null(msg);
}
[Fact]
public async Task RetryPendingMessagesAsync_MaxRetriesReached_ParksMessage()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("always fails"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 2);
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
}
[Fact]
public async Task RetryPendingMessagesAsync_PermanentFailureOnRetry_ParksMessage()
{
int callCount = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ =>
{
callCount++;
if (callCount == 1) throw new HttpRequestException("transient");
return Task.FromResult(false);
});
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
}
// ── WP-12: Parked message management ──
[Fact]
public async Task RetryParkedMessageAsync_MovesBackToQueue()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 1);
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
var retried = await _service.RetryParkedMessageAsync(result.MessageId);
Assert.True(retried);
msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
Assert.Equal(0, msg.RetryCount);
}
[Fact]
public async Task DiscardParkedMessageAsync_PermanentlyRemoves()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 1);
await _service.RetryPendingMessagesAsync();
var discarded = await _service.DiscardParkedMessageAsync(result.MessageId);
Assert.True(discarded);
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Null(msg);
}
[Fact]
public async Task GetParkedMessagesAsync_ReturnsPaginatedResults()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
for (int i = 0; i < 3; i++)
{
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, $"api{i}", """{}""",
maxRetries: 1);
}
await _service.RetryPendingMessagesAsync();
var (messages, total) = await _service.GetParkedMessagesAsync(
StoreAndForwardCategory.ExternalSystem, 1, 2);
Assert.Equal(2, messages.Count);
Assert.True(total >= 3);
}
// ── WP-13: Messages survive instance deletion ──
[Fact]
public async Task MessagesForInstance_SurviveAfterDeletion()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""", "Pump1");
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api2", """{}""", "Pump1");
var count = await _service.GetMessageCountForInstanceAsync("Pump1");
Assert.Equal(2, count);
}
// ── WP-14: Health metrics ──
[Fact]
public async Task GetBufferDepthAsync_ReturnsCorrectDepth()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
_service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
_ => throw new HttpRequestException("fail"));
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api1", """{}""");
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api2", """{}""");
await _service.EnqueueAsync(StoreAndForwardCategory.Notification, "email", """{}""");
var depth = await _service.GetBufferDepthAsync();
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.ExternalSystem) >= 2);
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.Notification) >= 1);
}
[Fact]
public async Task OnActivity_RaisedOnEnqueue()
{
var activities = new List<string>();
_service.OnActivity += (action, _, _) => activities.Add(action);
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api", """{}""");
Assert.Contains("Delivered", activities);
}
[Fact]
public async Task OnActivity_RaisedOnBuffer()
{
var activities = new List<string>();
_service.OnActivity += (action, _, _) => activities.Add(action);
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api", """{}""");
Assert.Contains("Queued", activities);
}
// ── WP-10: Per-source-entity retry settings ──
[Fact]
public async Task EnqueueAsync_CustomRetrySettings_Respected()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 100,
retryInterval: TimeSpan.FromSeconds(60));
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(100, msg!.MaxRetries);
Assert.Equal(60000, msg.RetryIntervalMs);
}
}

View File

@@ -0,0 +1,224 @@
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
};
}
}

View File

@@ -1,10 +0,0 @@
namespace ScadaLink.StoreAndForward.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false
}