using System.Reflection; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Emby; using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.Plex; using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Infrastructure.Locking; using ErsatzTV.Services; using LanguageExt; using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Services; /// /// Regression net for the #250 cross-release bug on the per-provider collections lock (#235): /// a scheduler-enqueued collection scan running with Unlock: false must NOT release a /// collections lock owned by another party (an in-flight API scan), while Unlock: true /// (an API request or the last message of a scheduler batch) must release on completion. /// [TestFixture] public class ScannerServiceCollectionLockTests { [Test] public async Task Plex_UnlockFalse_DoesNotReleaseHeldLock() { (ScannerService service, EntityLocker locker) = BuildService( new SynchronizePlexCollections(1, false, false, Unlock: false)); // another party (an API scan) owns the collections lock locker.LockPlexCollections().ShouldBeTrue(); await RunToCompletion(service); locker.ArePlexCollectionsLocked().ShouldBeTrue(); } [Test] public async Task Plex_UnlockTrue_ReleasesHeldLock() { (ScannerService service, EntityLocker locker) = BuildService( new SynchronizePlexCollections(1, false, false, Unlock: true)); locker.LockPlexCollections().ShouldBeTrue(); await RunToCompletion(service); locker.ArePlexCollectionsLocked().ShouldBeFalse(); } [Test] public async Task Jellyfin_UnlockFalse_DoesNotReleaseHeldLock() { (ScannerService service, EntityLocker locker) = BuildService( new SynchronizeJellyfinCollections(1, false, false, Unlock: false)); locker.LockJellyfinCollections().ShouldBeTrue(); await RunToCompletion(service); locker.AreJellyfinCollectionsLocked().ShouldBeTrue(); } [Test] public async Task Jellyfin_UnlockTrue_ReleasesHeldLock() { (ScannerService service, EntityLocker locker) = BuildService( new SynchronizeJellyfinCollections(1, false, false, Unlock: true)); locker.LockJellyfinCollections().ShouldBeTrue(); await RunToCompletion(service); locker.AreJellyfinCollectionsLocked().ShouldBeFalse(); } [Test] public async Task Emby_UnlockFalse_DoesNotReleaseHeldLock() { (ScannerService service, EntityLocker locker) = BuildService( new SynchronizeEmbyCollections(1, false, false, Unlock: false)); locker.LockEmbyCollections().ShouldBeTrue(); await RunToCompletion(service); locker.AreEmbyCollectionsLocked().ShouldBeTrue(); } [Test] public async Task Emby_UnlockTrue_ReleasesHeldLock() { (ScannerService service, EntityLocker locker) = BuildService( new SynchronizeEmbyCollections(1, false, false, Unlock: true)); locker.LockEmbyCollections().ShouldBeTrue(); await RunToCompletion(service); locker.AreEmbyCollectionsLocked().ShouldBeFalse(); } private static (ScannerService, EntityLocker) BuildService(IScannerBackgroundServiceRequest request) { var mediator = Substitute.For(); // every collection sync request returns success; the finally is what we're exercising Either ok = LanguageExt.Unit.Default; mediator.Send(Arg.Any(), Arg.Any()) .Returns(ok); mediator.Send(Arg.Any(), Arg.Any()) .Returns(ok); mediator.Send(Arg.Any(), Arg.Any()) .Returns(ok); var locker = new EntityLocker(mediator, NullLogger.Instance); var provider = Substitute.For(); provider.GetService(typeof(IMediator)).Returns(mediator); provider.GetService(typeof(IEntityLocker)).Returns(locker); var scope = Substitute.For(); scope.ServiceProvider.Returns(provider); var scopeFactory = Substitute.For(); scopeFactory.CreateScope().Returns(scope); var channel = Channel.CreateUnbounded(); channel.Writer.TryWrite(request).ShouldBeTrue(); channel.Writer.Complete(); var startup = new SystemStartup(); startup.DatabaseIsReady(); startup.SearchIndexIsReady(); var service = new ScannerService( channel.Reader, scopeFactory, startup, NullLogger.Instance); return (service, locker); } private static async Task RunToCompletion(ScannerService service) { MethodInfo executeAsync = typeof(ScannerService) .GetMethod("ExecuteAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; // the channel writer is already completed, so the read loop drains the one queued // request, runs its finally, then ExecuteAsync returns var task = (Task)executeAsync.Invoke(service, [CancellationToken.None])!; await task.WaitAsync(TimeSpan.FromSeconds(10)); } }