using System.Reflection; using System.Threading.Channels; using SysChannels = System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Plex; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Services; using ErsatzTV.Tests.Support; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Services; /// /// Verifies the scheduler's lock-once-per-provider collection batch (#235): it acquires /// LockPlexCollections() exactly once, and when the slot is already held (an API scan or a /// prior tick) it SKIPS the collection enqueue entirely rather than enqueuing an unlocked scan /// that would cross-release the holder's lock (#250). When it does acquire, only the LAST message /// carries the release (Unlock: true). /// [TestFixture] public class SchedulerServiceCollectionLockTests { private InMemoryTvContext _db; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task Plex_SkipsCollectionEnqueue_WhenCollectionsLockAlreadyHeld() { await SeedPlexLibrary(id: 1, mediaSourceId: 7); var locker = Substitute.For(); // isolate the collection path: the library loop enqueues nothing but still records the source id locker.LockLibrary(Arg.Any()).Returns(false); // the collections slot is already held by another party locker.LockPlexCollections().Returns(false); (SchedulerService service, SysChannels.Channel scannerChannel) = BuildService(locker); await InvokeScanPlex(service); List writes = await Drain(scannerChannel); locker.Received(1).LockPlexCollections(); writes.OfType().ShouldBeEmpty(); locker.DidNotReceive().UnlockPlexCollections(); } [Test] public async Task Plex_EnqueuesBatch_LastMessageOwnsRelease_WhenLockAcquired() { await SeedPlexLibrary(id: 1, mediaSourceId: 7); await SeedPlexLibrary(id: 2, mediaSourceId: 8); var locker = Substitute.For(); locker.LockLibrary(Arg.Any()).Returns(false); locker.LockPlexCollections().Returns(true); (SchedulerService service, SysChannels.Channel scannerChannel) = BuildService(locker); await InvokeScanPlex(service); List writes = await Drain(scannerChannel); locker.Received(1).LockPlexCollections(); List collectionScans = writes.OfType().ToList(); collectionScans.Count.ShouldBe(2); // exactly one message owns the single release, and it is the last one written collectionScans.Count(m => m.Unlock).ShouldBe(1); collectionScans.Last().Unlock.ShouldBeTrue(); collectionScans.First().Unlock.ShouldBeFalse(); // release is handed off to the last message; no compensating unlock on the happy path locker.DidNotReceive().UnlockPlexCollections(); } private async Task SeedPlexLibrary(int id, int mediaSourceId) { await using TvContext context = _db.CreateContext(); context.PlexLibraries.Add( new PlexLibrary { Id = id, Name = $"Plex {id}", MediaKind = LibraryMediaKind.Movies, MediaSourceId = mediaSourceId, ShouldSyncItems = true, Key = $"key-{id}" }); await context.SaveChangesAsync(); } private (SchedulerService, SysChannels.Channel) BuildService(IEntityLocker locker) { var provider = Substitute.For(); provider.GetService(typeof(TvContext)).Returns(_ => _db.CreateContext()); var scope = Substitute.For(); scope.ServiceProvider.Returns(provider); var scopeFactory = Substitute.For(); scopeFactory.CreateScope().Returns(scope); var workerChannel = SysChannels.Channel.CreateUnbounded(); var scannerChannel = SysChannels.Channel.CreateUnbounded(); var startup = new SystemStartup(); var service = new SchedulerService( scopeFactory, workerChannel.Writer, scannerChannel.Writer, locker, startup, NullLogger.Instance); return (service, scannerChannel); } private static async Task> Drain( SysChannels.Channel channel) { channel.Writer.Complete(); var results = new List(); await foreach (IScannerBackgroundServiceRequest request in channel.Reader.ReadAllAsync()) { results.Add(request); } return results; } private static async Task InvokeScanPlex(SchedulerService service) { MethodInfo method = typeof(SchedulerService) .GetMethod("ScanPlexMediaSources", BindingFlags.NonPublic | BindingFlags.Instance)!; var task = (Task)method.Invoke(service, [CancellationToken.None])!; await task.WaitAsync(TimeSpan.FromSeconds(10)); } }