Scan queue handler now returns a QueueLibraryScanResult enum (Queued|NotFound|SyncDisabled|AlreadyScanning) instead of a lying bool; LibrariesController.ScanLibrary maps them to 202/404/422/409 with ProblemDetails. Guard the lock->enqueue with the EnqueueWithTraktLock compensating-unlock pattern. ScannerService now releases every library/collection lock in a finally so a handler exception can't leak the lock. Plex "Shows" scheduler batch (one lock, two messages) now has only the trailing SynchronizePlexNetworks carry the single release (Unlock flag), mirroring the scheduler Trakt tail-token precedent. Guard the other lock->enqueue producers (Create/UpdateLocalLibrary, UpdateTraktList) with compensating unlock. SPA drops the PENDING_GRACE_TICKS heuristic now that the POST reports 202/409/404/422 directly: 202 -> pending+poll, 409 -> reconcile (no error toast), 404/422 -> surface error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
8.3 KiB
C#
225 lines
8.3 KiB
C#
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<IEntityLocker>();
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
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<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off()
|
|
{
|
|
int libraryId = await SeedSyncDisabledPlexLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
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<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Queue_And_Enqueue_When_Lock_Acquired()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
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<int>());
|
|
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
|
|
request.ShouldBeOfType<ForceScanLocalLibrary>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(false);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
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<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Two_Concurrent_Scans_Should_Be_Queued_Then_AlreadyScanning()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
// first acquire wins, second loses (a scan is already in progress)
|
|
locker.LockLibrary(libraryId).Returns(true, false);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
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<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
// a completed writer makes WriteAsync throw ChannelClosedException, simulating a failed enqueue
|
|
channel.Writer.Complete();
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
|
|
|
await Should.ThrowAsync<ChannelClosedException>(() =>
|
|
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<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
|
|
// a writer whose enqueue is aborted (e.g. request cancelled after the lock was taken)
|
|
var writer = Substitute.For<ChannelWriter<IScannerBackgroundServiceRequest>>();
|
|
writer.WriteAsync(Arg.Any<IScannerBackgroundServiceRequest>(), Arg.Any<CancellationToken>())
|
|
.Returns(ValueTask.FromException(new OperationCanceledException()));
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, writer);
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(() =>
|
|
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
|
|
|
|
locker.Received(1).UnlockLibrary(libraryId);
|
|
}
|
|
|
|
private QueueLibraryScanByLibraryIdHandler CreateHandler(
|
|
IEntityLocker locker,
|
|
ChannelWriter<IScannerBackgroundServiceRequest> writer) =>
|
|
new(
|
|
_db.Factory,
|
|
locker,
|
|
writer,
|
|
NullLogger<QueueLibraryScanByLibraryIdHandler>.Instance);
|
|
|
|
private async Task<int> 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<int> SeedSyncDisabledPlexLibrary()
|
|
{
|
|
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 = false,
|
|
Paths = []
|
|
}
|
|
]
|
|
};
|
|
await context.PlexMediaSources.AddAsync(source);
|
|
await context.SaveChangesAsync();
|
|
return source.Libraries[0].Id;
|
|
}
|
|
}
|