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