using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; 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(); _libraryRepository = Substitute.For(); _configElementRepository = Substitute.For(); _movieFolderScanner = Substitute.For(); _logger = Substitute.For>(); _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).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 _logger; private LocalLibrary _library; [Test] public async Task Should_Set_Library_LastScan_After_Successful_Scan() { ScanResult(Right(Unit.Default)); Either 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(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()); // 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_Log_Error_When_A_Path_Scan_Is_Canceled() { ScanResult(Left(new ScanCanceled())); await Handler().Handle( new ScanLocalLibrary("http://ersatztv.example", _library.Id, true), CancellationToken.None); // a user-initiated cancellation is not a failure and must not be logged at ERROR (#410); // it's still correctly excluded from the "last scan" stamp, same as any other failure _library.LastScan.ShouldBeNull(); await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any()); _logger.ReceivedCalls() .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() == "Error scanning local library path /movies: Scan was canceled") .ShouldBeFalse(); ShouldHaveLogged("Scan of local library path /movies was canceled"); } [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(), Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default).AsTask()); _movieFolderScanner.ScanFolder( badPath, _ffmpegPath, _ffprobePath, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Left(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()); await _libraryRepository.Received(1).UpdateLastScan(goodPath); } [Test] public async Task Should_Not_Set_Library_LastScan_When_Scan_Is_Skipped() { ScanResult(Right(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()); } private void ConfigValue(ConfigElementKey key, T value) => _configElementRepository.GetValue( Arg.Is(k => k.Key == key.Key), Arg.Any()) .Returns(Task.FromResult>(Some(value))); private void ScanResult(Either result) => _movieFolderScanner.ScanFolder( Arg.Any(), _ffmpegPath, _ffprobePath, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(result.AsTask()); private ScanLocalLibraryHandler Handler() => new( _scannerProxy, _libraryRepository, _configElementRepository, _movieFolderScanner, Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), _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(); } }