Files
ersatztv/ErsatzTV.Tests/Application/Libraries/QueueShowScanByLibraryIdHandlerTests.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

215 lines
7.5 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using ThreadingChannel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Libraries;
[TestFixture]
public class QueueShowScanByLibraryIdHandlerTests
{
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>();
var mediator = Substitute.For<IMediator>();
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
QueueShowScanResult result = await handler.Handle(
new QueueShowScanByLibraryId(9999, 1, "Show", false),
CancellationToken.None);
result.ShouldBe(QueueShowScanResult.NotFound);
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
}
[Test]
public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off()
{
int libraryId = await SeedPlexLibrary(shouldSyncItems: false);
var locker = Substitute.For<IEntityLocker>();
var mediator = Substitute.For<IMediator>();
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
QueueShowScanResult result = await handler.Handle(
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
CancellationToken.None);
result.ShouldBe(QueueShowScanResult.SyncDisabled);
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
}
[Test]
public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired()
{
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(libraryId).Returns(false);
var mediator = Substitute.For<IMediator>();
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
QueueShowScanResult result = await handler.Handle(
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
CancellationToken.None);
result.ShouldBe(QueueShowScanResult.AlreadyScanning);
await mediator.DidNotReceive().Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Return_Unsupported_For_Local_Library()
{
int libraryId = await SeedLocalLibrary();
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(libraryId).Returns(true);
var mediator = Substitute.For<IMediator>();
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
QueueShowScanResult result = await handler.Handle(
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
CancellationToken.None);
result.ShouldBe(QueueShowScanResult.Unsupported);
locker.Received(1).UnlockLibrary(libraryId);
}
[Test]
public async Task Handle_Should_Return_Queued_And_Extract_Subtitles_On_Deep_Success()
{
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(libraryId).Returns(true);
var mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, string>("ok"));
QueueShowScanByLibraryIdHandler handler = CreateHandler(
locker,
mediator,
out Channel<IBackgroundServiceRequest> worker);
QueueShowScanResult result = await handler.Handle(
new QueueShowScanByLibraryId(libraryId, 42, "Show", DeepScan: true),
CancellationToken.None);
result.ShouldBe(QueueShowScanResult.Queued);
locker.Received(1).UnlockLibrary(libraryId);
worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
request.ShouldBeOfType<ExtractEmbeddedShowSubtitles>().ShowId.ShouldBe(42);
}
[Test]
public async Task Handle_Should_Return_ScanFailed_When_SubScan_Left()
{
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(libraryId).Returns(true);
var mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, string>(BaseError.New("scan error")));
QueueShowScanByLibraryIdHandler handler = CreateHandler(
locker,
mediator,
out Channel<IBackgroundServiceRequest> worker);
QueueShowScanResult result = await handler.Handle(
new QueueShowScanByLibraryId(libraryId, 42, "Show", DeepScan: true),
CancellationToken.None);
result.ShouldBe(QueueShowScanResult.ScanFailed);
locker.Received(1).UnlockLibrary(libraryId);
// no subtitle extraction on a failed scan
worker.Reader.TryRead(out _).ShouldBeFalse();
}
private QueueShowScanByLibraryIdHandler CreateHandler(
IEntityLocker locker,
IMediator mediator,
out Channel<IBackgroundServiceRequest> worker)
{
worker = ThreadingChannel.CreateUnbounded<IBackgroundServiceRequest>();
return new QueueShowScanByLibraryIdHandler(
_db.Factory,
locker,
mediator,
worker.Writer,
NullLogger<QueueShowScanByLibraryIdHandler>.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> 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 Shows",
MediaKind = LibraryMediaKind.Shows,
Key = "1",
ShouldSyncItems = shouldSyncItems,
Paths = []
}
]
};
await context.PlexMediaSources.AddAsync(source);
await context.SaveChangesAsync();
return source.Libraries[0].Id;
}
}