Files
ersatztv/ErsatzTV.Tests/Application/Libraries/QueueLibraryScanByLibraryIdHandlerTests.cs
T
timothyandClaude Opus 4.8 628c9d7228 feat(235): F9 API parity — library deep-scan, external-collections scan, scan-show outcome enum (#235 slice B)
Closes the two F9 Libraries.razor parity gaps and normalizes scan-show error
mapping to ProblemDetails.

TASK 1 — library-wide deep scan:
- QueueLibraryScanByLibraryId gains optional `bool DeepScan = false`; handler
  threads it into ForceSynchronize{Plex,Jellyfin,Emby}LibraryById.
- POST /api/libraries/{id}/scan?deep=false binds it via [FromQuery].

TASK 2 — external-collections scan (new endpoints):
- POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false
  acquires the per-source collections lock (§3b: lock IS the running scan → 409),
  enqueues Synchronize{X}Collections(id, ForceScan:true, deep) to the scanner
  channel, returns 202; compensating-unlock on enqueue throw.

TASK 3 — scan-show normalization:
- New QueueShowScanResult enum; handler returns it instead of bool.
- POST /api/libraries/{id}/scan-show now maps 202/404/409/422 (all errors
  ProblemDetails) instead of 200/404/400-anonymous-object.
- Updated the lone Blazor caller (TelevisionSeasonList.razor).

Tests: LibrariesController (scan deep=true, scan-show enum→status), the three
media-source controllers (scan-collections route/404/409/202/compensating-unlock),
and handler tests for both changed handlers (deep threading + show-scan outcomes).
Docs: api-conventions §3b exemplar + blazor-route-parity §5 F9 gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:02:17 +02:00

253 lines
9.7 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);
}
[Test]
public async Task Handle_Should_Thread_DeepScan_Into_Plex_ForceSynchronize()
{
int libraryId = await SeedSyncEnabledPlexLibrary();
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, 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<ErsatzTV.Application.Plex.SynchronizePlexLibraries>();
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue();
second.ShouldBeOfType<ErsatzTV.Application.Plex.ForceSynchronizePlexLibraryById>().DeepScan.ShouldBeTrue();
}
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 SeedPlexLibrary(shouldSyncItems: false);
private async Task<int> SeedSyncEnabledPlexLibrary() => await SeedPlexLibrary(shouldSyncItems: true);
private async Task<int> 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;
}
}