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
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.
187 lines
7.5 KiB
C#
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();
|
|
}
|
|
}
|