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.
96 lines
3.0 KiB
C#
96 lines
3.0 KiB
C#
namespace ScadaLink.DeploymentManager.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-3: Tests for per-instance operation lock.
|
|
/// </summary>
|
|
public class OperationLockManagerTests
|
|
{
|
|
private readonly OperationLockManager _lockManager = new();
|
|
|
|
[Fact]
|
|
public async Task AcquireAsync_ReturnsDisposable()
|
|
{
|
|
using var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
|
Assert.NotNull(lockHandle);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcquireAsync_SameInstance_BlocksSecondCaller()
|
|
{
|
|
using var firstLock = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
|
|
|
// Second acquire should time out
|
|
await Assert.ThrowsAsync<TimeoutException>(() =>
|
|
_lockManager.AcquireAsync("inst1", TimeSpan.FromMilliseconds(50)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcquireAsync_DifferentInstances_BothSucceed()
|
|
{
|
|
using var lock1 = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
|
using var lock2 = await _lockManager.AcquireAsync("inst2", TimeSpan.FromSeconds(5));
|
|
|
|
Assert.NotNull(lock1);
|
|
Assert.NotNull(lock2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcquireAsync_AfterRelease_CanReacquire()
|
|
{
|
|
var firstLock = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
|
firstLock.Dispose();
|
|
|
|
// Should succeed now
|
|
using var secondLock = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
|
Assert.NotNull(secondLock);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsLocked_ReturnsTrueWhileLocked()
|
|
{
|
|
Assert.False(_lockManager.IsLocked("inst1"));
|
|
|
|
using var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
|
Assert.True(_lockManager.IsLocked("inst1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsLocked_ReturnsFalseAfterRelease()
|
|
{
|
|
var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
|
lockHandle.Dispose();
|
|
|
|
Assert.False(_lockManager.IsLocked("inst1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcquireAsync_DoubleDispose_DoesNotThrow()
|
|
{
|
|
var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
|
lockHandle.Dispose();
|
|
lockHandle.Dispose(); // Should not throw
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcquireAsync_CancellationToken_Respected()
|
|
{
|
|
using var firstLock = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(30));
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
|
_lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(30), cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcquireAsync_ConcurrentDifferentInstances_AllSucceed()
|
|
{
|
|
var tasks = Enumerable.Range(0, 10).Select(async i =>
|
|
{
|
|
using var lockHandle = await _lockManager.AcquireAsync($"inst{i}", TimeSpan.FromSeconds(5));
|
|
await Task.Delay(10);
|
|
});
|
|
|
|
await Task.WhenAll(tasks);
|
|
}
|
|
}
|