Files
ersatztv/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs
T
timothy 71708633f5
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 38s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 10m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 15m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
test(264): pin the path-scan-error log; correct testing.md accuracy
Re-review of the previous fix commit (986ccfaf, verdict MERGEABLE)
raised one fair hit and one claim that did not survive checking.

Fair hit — that commit's message asserted "LogError is an extension
method that NSubstitute can't cleanly verify" as the reason for adding
no test. That is FALSE, and this repo disproves it:
ErsatzTV.Tests' ReleaseMemoryHandlerTests.ShouldHaveLogged asserts on
ILogger via ReceivedCalls(), which works precisely because the LogError
extension dispatches to the substituted ILogger.Log. Reusing that idiom
here costs 4 lines, so pin the log the previous commit added. Verified
non-vacuous: asserting a message the handler never logs fails the test.

Not applied — the same review called the row's "1471 tests" misleading
on the grounds that TranscodingTests is [Explicit] and contributes most
of that count. TranscodingTests is indeed [Explicit], but filtering it
out yields exactly 1471, so 1471 is already the runnable count and the
row was accurate. Kept the number; documented TranscodingTests as
[Explicit]/opt-in instead, since it was a genuine omission from a table
that claims to be authoritative.

Also rewrap the verification-gate paragraph the previous commit left
over-long, and name the two always-run projects instead of "both".

Deferred (filed separately): a canceled local scan now logs at ERROR
per path via this log. It mirrors the remote handlers exactly, so
diverging here would be the inconsistency, not the fix.
2026-07-17 14:35:09 +02:00

187 lines
7.5 KiB
C#

using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Scanner.Application.MediaSources;
using ErsatzTV.Scanner.Core.Interfaces;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Scanner.Tests.Application.MediaSources;
public class ScanLocalLibraryHandlerTests
{
[TestFixture]
public class Handle
{
[SetUp]
public void SetUp()
{
// the handler validates that the configured ffmpeg/ffprobe paths exist on disk
_ffmpegPath = Path.GetTempFileName();
_ffprobePath = Path.GetTempFileName();
_scannerProxy = Substitute.For<IScannerProxy>();
_libraryRepository = Substitute.For<ILibraryRepository>();
_configElementRepository = Substitute.For<IConfigElementRepository>();
_movieFolderScanner = Substitute.For<IMovieFolderScanner>();
_logger = Substitute.For<ILogger<ScanLocalLibraryHandler>>();
_library = new LocalLibrary
{
Id = 42,
Name = "Movies",
MediaKind = LibraryMediaKind.Movies,
MediaSourceId = 1,
Paths = [new LibraryPath { Id = 1, Path = "/movies" }]
};
_libraryRepository.GetLibrary(_library.Id).Returns(Some<Library>(_library).AsTask());
ConfigValue(ConfigElementKey.FFmpegPath, _ffmpegPath);
ConfigValue(ConfigElementKey.FFprobePath, _ffprobePath);
ConfigValue(ConfigElementKey.LibraryRefreshInterval, 0);
}
[TearDown]
public void TearDown()
{
File.Delete(_ffmpegPath);
File.Delete(_ffprobePath);
}
private string _ffmpegPath;
private string _ffprobePath;
private IScannerProxy _scannerProxy;
private ILibraryRepository _libraryRepository;
private IConfigElementRepository _configElementRepository;
private IMovieFolderScanner _movieFolderScanner;
private ILogger<ScanLocalLibraryHandler> _logger;
private LocalLibrary _library;
[Test]
public async Task Should_Set_Library_LastScan_After_Successful_Scan()
{
ScanResult(Right<BaseError, Unit>(Unit.Default));
Either<BaseError, string> result = await Handler().Handle(
new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
// the regression: only the path-level LastScan was written, so the API's
// library-level scan time stayed null and the SPA showed "Never scanned" forever
_library.LastScan.ShouldNotBeNull();
await _libraryRepository.Received(1).UpdateLastScan(_library);
await _libraryRepository.Received(1).UpdateLastScan(_library.Paths[0]);
}
[Test]
public async Task Should_Not_Set_Library_LastScan_When_A_Path_Scan_Fails()
{
ScanResult(Left<BaseError, Unit>(new BaseError("scan failed")));
await Handler().Handle(
new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
CancellationToken.None);
_library.LastScan.ShouldBeNull();
await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
// a failed path suppresses the library-level scan time, reproducing the #264 symptom;
// without this log the user would have nothing explaining why
ShouldHaveLogged("Error scanning local library path /movies: scan failed");
}
[Test]
public async Task Should_Not_Set_Library_LastScan_When_A_Later_Path_Fails()
{
var goodPath = new LibraryPath { Id = 1, Path = "/movies" };
var badPath = new LibraryPath { Id = 2, Path = "/more-movies" };
_library.Paths = [goodPath, badPath];
_movieFolderScanner.ScanFolder(
goodPath,
_ffmpegPath,
_ffprobePath,
Arg.Any<decimal>(),
Arg.Any<decimal>(),
Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
_movieFolderScanner.ScanFolder(
badPath,
_ffmpegPath,
_ffprobePath,
Arg.Any<decimal>(),
Arg.Any<decimal>(),
Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new BaseError("scan failed")).AsTask());
await Handler().Handle(
new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
CancellationToken.None);
// a partially-scanned library must not claim a successful scan time
_library.LastScan.ShouldBeNull();
await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
await _libraryRepository.Received(1).UpdateLastScan(goodPath);
}
[Test]
public async Task Should_Not_Set_Library_LastScan_When_Scan_Is_Skipped()
{
ScanResult(Right<BaseError, Unit>(Unit.Default));
_library.Paths[0].LastScan = DateTime.UtcNow;
ConfigValue(ConfigElementKey.LibraryRefreshInterval, 6);
await Handler().Handle(
new ScanLocalLibrary("http://ersatztv.example", _library.Id, false),
CancellationToken.None);
// nothing was scanned, so there is no new scan time to record
_library.LastScan.ShouldBeNull();
await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
}
private void ConfigValue<T>(ConfigElementKey key, T value) =>
_configElementRepository.GetValue<T>(
Arg.Is<ConfigElementKey>(k => k.Key == key.Key),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Option<T>>(Some(value)));
private void ScanResult(Either<BaseError, Unit> result) =>
_movieFolderScanner.ScanFolder(
Arg.Any<LibraryPath>(),
_ffmpegPath,
_ffprobePath,
Arg.Any<decimal>(),
Arg.Any<decimal>(),
Arg.Any<CancellationToken>())
.Returns(result.AsTask());
private ScanLocalLibraryHandler Handler() =>
new(
_scannerProxy,
_libraryRepository,
_configElementRepository,
_movieFolderScanner,
Substitute.For<ITelevisionFolderScanner>(),
Substitute.For<IMusicVideoFolderScanner>(),
Substitute.For<IOtherVideoFolderScanner>(),
Substitute.For<ISongFolderScanner>(),
Substitute.For<IImageFolderScanner>(),
Substitute.For<IRemoteStreamFolderScanner>(),
_logger);
// asserts on the substituted ILogger.Log call the LogError extension dispatches to
// (same idiom as ErsatzTV.Tests' ReleaseMemoryHandlerTests.ShouldHaveLogged)
private void ShouldHaveLogged(string expectedMessage) =>
_logger.ReceivedCalls()
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() == expectedMessage)
.ShouldBeTrue();
}
}