diff --git a/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs b/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs new file mode 100644 index 000000000..f702f75bd --- /dev/null +++ b/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs @@ -0,0 +1,173 @@ +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(); + _libraryRepository = Substitute.For(); + _configElementRepository = Substitute.For(); + _movieFolderScanner = 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 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()); + } + + [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(), + Substitute.For>()); + } +} diff --git a/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs b/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs index e2ec05a77..f24a68818 100644 --- a/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs +++ b/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -65,6 +65,7 @@ public class ScanLocalLibraryHandler : IRequestHandler