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));
}
}