using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Libraries; using ErsatzTV.Application.MediaSources; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NUnit.Framework; using Shouldly; using ThreadingChannel = System.Threading.Channels.Channel; namespace ErsatzTV.Tests.Application.Libraries; [TestFixture] public class QueueLibraryScanByLibraryIdHandlerTests { private InMemoryTvContext _db = null!; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task Handle_Should_Return_NotFound_When_Library_Missing() { var locker = Substitute.For(); Channel channel = ThreadingChannel.CreateUnbounded(); QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer); QueueLibraryScanResult result = await handler.Handle( new QueueLibraryScanByLibraryId(9999), CancellationToken.None); result.ShouldBe(QueueLibraryScanResult.NotFound); locker.DidNotReceive().LockLibrary(Arg.Any()); } [Test] public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off() { int libraryId = await SeedSyncDisabledPlexLibrary(); var locker = Substitute.For(); Channel channel = ThreadingChannel.CreateUnbounded(); QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer); QueueLibraryScanResult result = await handler.Handle( new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None); result.ShouldBe(QueueLibraryScanResult.SyncDisabled); locker.DidNotReceive().LockLibrary(Arg.Any()); } [Test] public async Task Handle_Should_Queue_And_Enqueue_When_Lock_Acquired() { int libraryId = await SeedLocalLibrary(); var locker = Substitute.For(); locker.LockLibrary(libraryId).Returns(true); Channel channel = ThreadingChannel.CreateUnbounded(); QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer); QueueLibraryScanResult result = await handler.Handle( new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None); result.ShouldBe(QueueLibraryScanResult.Queued); locker.Received(1).LockLibrary(libraryId); locker.DidNotReceive().UnlockLibrary(Arg.Any()); channel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue(); request.ShouldBeOfType(); } [Test] public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired() { int libraryId = await SeedLocalLibrary(); var locker = Substitute.For(); locker.LockLibrary(libraryId).Returns(false); Channel channel = ThreadingChannel.CreateUnbounded(); QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer); QueueLibraryScanResult result = await handler.Handle( new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None); result.ShouldBe(QueueLibraryScanResult.AlreadyScanning); channel.Reader.TryRead(out _).ShouldBeFalse(); locker.DidNotReceive().UnlockLibrary(Arg.Any()); } [Test] public async Task Two_Concurrent_Scans_Should_Be_Queued_Then_AlreadyScanning() { int libraryId = await SeedLocalLibrary(); var locker = Substitute.For(); // first acquire wins, second loses (a scan is already in progress) locker.LockLibrary(libraryId).Returns(true, false); Channel channel = ThreadingChannel.CreateUnbounded(); QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer); QueueLibraryScanResult first = await handler.Handle( new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None); QueueLibraryScanResult second = await handler.Handle( new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None); first.ShouldBe(QueueLibraryScanResult.Queued); second.ShouldBe(QueueLibraryScanResult.AlreadyScanning); } [Test] public async Task Handle_Should_Unlock_And_Rethrow_When_Enqueue_Throws() { int libraryId = await SeedLocalLibrary(); var locker = Substitute.For(); locker.LockLibrary(libraryId).Returns(true); Channel channel = ThreadingChannel.CreateUnbounded(); // a completed writer makes WriteAsync throw ChannelClosedException, simulating a failed enqueue channel.Writer.Complete(); QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer); await Should.ThrowAsync(() => handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None)); locker.Received(1).UnlockLibrary(libraryId); } [Test] public async Task Handle_Should_Unlock_When_Enqueue_Canceled() { int libraryId = await SeedLocalLibrary(); var locker = Substitute.For(); locker.LockLibrary(libraryId).Returns(true); // a writer whose enqueue is aborted (e.g. request cancelled after the lock was taken) var writer = Substitute.For>(); writer.WriteAsync(Arg.Any(), Arg.Any()) .Returns(ValueTask.FromException(new OperationCanceledException())); QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, writer); await Should.ThrowAsync(() => handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None)); locker.Received(1).UnlockLibrary(libraryId); } [Test] public async Task Handle_Should_Thread_DeepScan_Into_Plex_ForceSynchronize() { int libraryId = await SeedSyncEnabledPlexLibrary(); var locker = Substitute.For(); locker.LockLibrary(libraryId).Returns(true); Channel channel = ThreadingChannel.CreateUnbounded(); QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer); QueueLibraryScanResult result = await handler.Handle( new QueueLibraryScanByLibraryId(libraryId, DeepScan: true), CancellationToken.None); result.ShouldBe(QueueLibraryScanResult.Queued); // first message refreshes the library list, second is the deep force-sync carrying DeepScan == true channel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue(); first.ShouldBeOfType(); channel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue(); second.ShouldBeOfType().DeepScan.ShouldBeTrue(); } private QueueLibraryScanByLibraryIdHandler CreateHandler( IEntityLocker locker, ChannelWriter writer) => new( _db.Factory, locker, writer, NullLogger.Instance); private async Task SeedLocalLibrary() { await using TvContext context = _db.CreateContext(); var source = new LocalMediaSource { Libraries = [ new LocalLibrary { Name = "Local Movies", MediaKind = LibraryMediaKind.Movies, Paths = [] } ] }; await context.LocalMediaSources.AddAsync(source); await context.SaveChangesAsync(); return source.Libraries[0].Id; } private async Task SeedSyncDisabledPlexLibrary() => await SeedPlexLibrary(shouldSyncItems: false); private async Task SeedSyncEnabledPlexLibrary() => await SeedPlexLibrary(shouldSyncItems: true); private async Task SeedPlexLibrary(bool shouldSyncItems) { await using TvContext context = _db.CreateContext(); var source = new PlexMediaSource { ServerName = "Plex Server", ProductVersion = "1", Platform = "Linux", PlatformVersion = "1", ClientIdentifier = "plex", Connections = [], PathReplacements = [], Libraries = [ new PlexLibrary { Name = "Plex Movies", MediaKind = LibraryMediaKind.Movies, Key = "1", ShouldSyncItems = shouldSyncItems, Paths = [] } ] }; await context.PlexMediaSources.AddAsync(source); await context.SaveChangesAsync(); return source.Libraries[0].Id; } }