namespace ScadaLink.DeploymentManager.Tests; /// /// WP-3: Tests for per-instance operation lock. /// 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(() => _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(() => _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); } // ── DeploymentManager-005: semaphore must not leak ── [Fact] public async Task AcquireAsync_ReleasedLock_RemovesSemaphoreEntry() { // A semaphore that is created, used, and fully released must not be // retained — otherwise every distinct instance name leaks a // SemaphoreSlim (a kernel handle) for the life of the process. using (await _lockManager.AcquireAsync("transient-inst", TimeSpan.FromSeconds(5))) { Assert.Equal(1, _lockManager.TrackedLockCount); } // After release, the entry is reclaimed. Assert.Equal(0, _lockManager.TrackedLockCount); } [Fact] public async Task AcquireAsync_ManyDistinctInstances_DoesNotAccumulateSemaphores() { // Simulates the long-running central process: many instances are // deployed/disabled over time. Their semaphores must be reclaimed. for (var i = 0; i < 100; i++) { using var handle = await _lockManager.AcquireAsync($"churn-{i}", TimeSpan.FromSeconds(5)); } Assert.Equal(0, _lockManager.TrackedLockCount); } [Fact] public async Task AcquireAsync_ContendedLock_KeepsSemaphoreUntilLastReleaseThenReclaims() { // While a second caller is waiting, the semaphore must survive the // first release; only when the last holder releases is it reclaimed. var first = await _lockManager.AcquireAsync("contended", TimeSpan.FromSeconds(5)); var secondAcquire = _lockManager.AcquireAsync("contended", TimeSpan.FromSeconds(5)); first.Dispose(); // hands the lock to the waiter; entry must NOT be removed var second = await secondAcquire; Assert.Equal(1, _lockManager.TrackedLockCount); second.Dispose(); Assert.Equal(0, _lockManager.TrackedLockCount); } }