namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// Tests for per-instance operation lock. /// public class OperationLockManagerTests { private readonly OperationLockManager _lockManager = new(); /// Verifies that acquiring the lock returns a disposable handle. /// A task that represents the asynchronous operation. [Fact] public async Task AcquireAsync_ReturnsDisposable() { using var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5)); Assert.NotNull(lockHandle); } /// Verifies that a second caller for the same instance is blocked until the lock is released. /// A task that represents the asynchronous operation. [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))); } /// Verifies that locks on different instances can both be acquired. /// A task that represents the asynchronous operation. [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); } /// Verifies that the lock can be re-acquired after it is released. /// A task that represents the asynchronous operation. [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); } /// Verifies that an instance reports as locked while the lock is held. /// A task that represents the asynchronous operation. [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")); } /// Verifies that an instance reports as not locked after the lock is released. /// A task that represents the asynchronous operation. [Fact] public async Task IsLocked_ReturnsFalseAfterRelease() { var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5)); lockHandle.Dispose(); Assert.False(_lockManager.IsLocked("inst1")); } /// Verifies that disposing the lock handle twice does not throw. /// A task that represents the asynchronous operation. [Fact] public async Task AcquireAsync_DoubleDispose_DoesNotThrow() { var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5)); lockHandle.Dispose(); lockHandle.Dispose(); // Should not throw } /// Verifies that acquiring the lock respects the cancellation token. /// A task that represents the asynchronous operation. [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)); } /// Verifies that concurrent acquisitions on different instances all succeed. /// A task that represents the asynchronous operation. [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); } // ── Semaphore must not leak ── /// Verifies that releasing a lock removes its semaphore entry. /// A task that represents the asynchronous operation. [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); } /// Verifies that many distinct instances do not accumulate semaphores. /// A task that represents the asynchronous operation. [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); } /// Verifies that a contended lock keeps its semaphore until the last release and then reclaims it. /// A task that represents the asynchronous operation. [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); } }