using ErsatzTV.Core.Domain; using ErsatzTV.Core.Notifications; using ErsatzTV.Infrastructure.Locking; using MediatR; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Infrastructure.Tests.Locking; // Verifies the atomicity + single-owner release contract from docs/decisions.md 2026-07-11 (issue #231). // These run against the real EntityLocker (not a mock) so the Interlocked/ConcurrentDictionary state is exercised. [TestFixture] public class EntityLockerTests { private const int ParallelCallers = 64; private static EntityLocker CreateLocker() => new(Substitute.For(), NullLogger.Instance); // Every exclusive single-slot kind, adapted to a uniform bool lock/unlock/is-locked shape. // Library bakes in a fixed id; RemoteMediaSource a concrete media-source type. Playout is async // (tested separately) and so is not in this source. private static IEnumerable ExclusiveWinnerKinds() { foreach (LockKind kind in EventCountingKinds()) { yield return kind; } yield return new LockKind { Name = "RemoteMediaSource", Lock = l => l.LockRemoteMediaSource(), Unlock = l => l.UnlockRemoteMediaSource(), IsLocked = l => l.IsRemoteMediaSourceLocked() }; } // The kinds whose change event is a plain EventHandler, so a subscriber can count transitions. private static IEnumerable EventCountingKinds() { yield return new LockKind { Name = "Plex", Lock = l => l.LockPlex(), Unlock = l => l.UnlockPlex(), IsLocked = l => l.IsPlexLocked(), Subscribe = (l, h) => l.OnPlexChanged += h }; yield return new LockKind { Name = "Trakt", Lock = l => l.LockTrakt(), Unlock = l => l.UnlockTrakt(), IsLocked = l => l.IsTraktLocked(), Subscribe = (l, h) => l.OnTraktChanged += h }; yield return new LockKind { Name = "EmbyCollections", Lock = l => l.LockEmbyCollections(), Unlock = l => l.UnlockEmbyCollections(), IsLocked = l => l.AreEmbyCollectionsLocked(), Subscribe = (l, h) => l.OnEmbyCollectionsChanged += h }; yield return new LockKind { Name = "JellyfinCollections", Lock = l => l.LockJellyfinCollections(), Unlock = l => l.UnlockJellyfinCollections(), IsLocked = l => l.AreJellyfinCollectionsLocked(), Subscribe = (l, h) => l.OnJellyfinCollectionsChanged += h }; yield return new LockKind { Name = "PlexCollections", Lock = l => l.LockPlexCollections(), Unlock = l => l.UnlockPlexCollections(), IsLocked = l => l.ArePlexCollectionsLocked(), Subscribe = (l, h) => l.OnPlexCollectionsChanged += h }; yield return new LockKind { Name = "TroubleshootingPlayback", Lock = l => l.LockTroubleshootingPlayback(), Unlock = l => l.UnlockTroubleshootingPlayback(), IsLocked = l => l.IsTroubleshootingPlaybackLocked(), Subscribe = (l, h) => l.OnTroubleshootingPlaybackChanged += h }; yield return new LockKind { Name = "Library", Lock = l => l.LockLibrary(1), Unlock = l => l.UnlockLibrary(1), IsLocked = l => l.IsLibraryLocked(1), Subscribe = (l, h) => l.OnLibraryChanged += h }; } // Stress the acquire race over many rounds: a single-round 64-thread barrier does NOT reliably // collide the non-atomic check-then-set window on fast hardware (verified: it green-lit a // deliberately broken flag), so this hammers Lock* for thousands of rounds — each round every // thread races one acquire, exactly one must win, then the winner releases to reset. Any round // with a winner count != 1 fails the whole test. Against the atomic CAS this is invariant; // against a non-atomic flag some round double-acquires. [Test] [TestCaseSource(nameof(ExclusiveWinnerKinds))] public void Lock_ParallelCallers_ExactlyOneWinner(LockKind kind) { const int threads = 8; const int rounds = 20_000; EntityLocker locker = CreateLocker(); var winners = 0; var badRounds = 0; using var startRound = new Barrier(threads); using var endRound = new Barrier( threads, _ => { if (Volatile.Read(ref winners) != 1) { Interlocked.Increment(ref badRounds); } // reset for the next round: release the (single) held lock and clear the counter kind.Unlock(locker); Volatile.Write(ref winners, 0); }); var workers = new Thread[threads]; for (var t = 0; t < threads; t++) { workers[t] = new Thread(() => { for (var r = 0; r < rounds; r++) { startRound.SignalAndWait(); if (kind.Lock(locker)) { Interlocked.Increment(ref winners); } endRound.SignalAndWait(); } }); workers[t].Start(); } foreach (Thread worker in workers) { worker.Join(); } badRounds.ShouldBe(0); kind.IsLocked(locker).ShouldBeFalse(); } [Test] public async Task LockPlayout_ParallelCallers_ExactlyOneWinner_PublishesOnce() { var mediator = Substitute.For(); var locker = new EntityLocker(mediator, NullLogger.Instance); const int playoutId = 7; using var barrier = new Barrier(ParallelCallers); Task[] tasks = Enumerable.Range(0, ParallelCallers) .Select(_ => Task.Run(async () => { barrier.SignalAndWait(); return await locker.LockPlayout(playoutId); })) .ToArray(); bool[] results = await Task.WhenAll(tasks); results.Count(r => r).ShouldBe(1); locker.IsPlayoutLocked(playoutId).ShouldBeTrue(); await mediator.Received(1).Publish( Arg.Is(n => n.PlayoutId == playoutId && n.IsLocked), Arg.Any()); } [Test] [TestCaseSource(nameof(EventCountingKinds))] public void Lock_WhenAlreadyLocked_ReturnsFalse_AndFiresNoSecondEvent(LockKind kind) { EntityLocker locker = CreateLocker(); var eventCount = 0; kind.Subscribe!(locker, (_, _) => eventCount++); kind.Lock(locker).ShouldBeTrue(); kind.Lock(locker).ShouldBeFalse(); kind.IsLocked(locker).ShouldBeTrue(); eventCount.ShouldBe(1); } [Test] [TestCaseSource(nameof(EventCountingKinds))] public void Unlock_WhenNotLocked_ReturnsFalse_AndFiresNoEvent(LockKind kind) { EntityLocker locker = CreateLocker(); var eventCount = 0; kind.Subscribe!(locker, (_, _) => eventCount++); kind.Unlock(locker).ShouldBeFalse(); eventCount.ShouldBe(0); kind.IsLocked(locker).ShouldBeFalse(); } [Test] [TestCaseSource(nameof(EventCountingKinds))] public void Unlock_AfterLock_ReturnsTrue_ThenSecondUnlockReturnsFalse(LockKind kind) { EntityLocker locker = CreateLocker(); var eventCount = 0; kind.Subscribe!(locker, (_, _) => eventCount++); kind.Lock(locker).ShouldBeTrue(); kind.Unlock(locker).ShouldBeTrue(); kind.Unlock(locker).ShouldBeFalse(); kind.IsLocked(locker).ShouldBeFalse(); eventCount.ShouldBe(2); // one lock transition + one unlock transition, the no-op unlock fires nothing } [Test] [TestCaseSource(nameof(EventCountingKinds))] public async Task ParallelUnlock_AfterSingleLock_ExactlyOneReleaser(LockKind kind) { EntityLocker locker = CreateLocker(); kind.Lock(locker).ShouldBeTrue(); var unlockEvents = 0; kind.Subscribe!(locker, (_, _) => Interlocked.Increment(ref unlockEvents)); using var barrier = new Barrier(ParallelCallers); Task[] tasks = Enumerable.Range(0, ParallelCallers) .Select(_ => Task.Run(() => { barrier.SignalAndWait(); return kind.Unlock(locker); })) .ToArray(); bool[] results = await Task.WhenAll(tasks); results.Count(r => r).ShouldBe(1); kind.IsLocked(locker).ShouldBeFalse(); unlockEvents.ShouldBe(1); } public sealed class LockKind { public required string Name { get; init; } public required Func Lock { get; init; } public required Func Unlock { get; init; } public required Func IsLocked { get; init; } // null for kinds whose change event is not a plain EventHandler (RemoteMediaSource is EventHandler). public Action? Subscribe { get; init; } public override string ToString() => Name; } }