using System.IO.Abstractions; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Libraries; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using NSubstitute; using NUnit.Framework; using Shouldly; using Testably.Abstractions.Testing; using ThreadingChannel = System.Threading.Channels.Channel; namespace ErsatzTV.Tests.Application.Libraries; [TestFixture] public class CreateLocalLibraryHandlerTests { 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_422_When_New_Path_Does_Not_Exist() { await SeedLocalMediaSource(); CreateLocalLibraryHandler handler = CreateHandler(new MockFileSystem()); Either result = await handler.Handle( new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies"]), CancellationToken.None); result.IsLeft.ShouldBeTrue(); result.IfLeft(error => error.Value.ShouldContain("/media/movies")); } // LibraryMediaKind.Mixed exists only for remote (Jellyfin) libraries, where the media server // classifies each item. No local folder scanner handles it, so a local Mixed library would fail // every scan forever and log at ERROR on every scheduler tick. The API takes a raw // LibraryMediaKind, so hiding it from the SPA dropdown is not enforcement (#489 review M1). [Test] public async Task Handle_Should_Reject_The_Mixed_Media_Kind_For_Local_Libraries() { await SeedLocalMediaSource(); var fileSystem = new MockFileSystem(); fileSystem.Directory.CreateDirectory("/media/music"); CreateLocalLibraryHandler handler = CreateHandler(fileSystem); Either result = await handler.Handle( new CreateLocalLibrary("Music", LibraryMediaKind.Mixed, ["/media/music"]), CancellationToken.None); result.IsLeft.ShouldBeTrue(); result.IfLeft(error => error.Value.ShouldContain("Mixed")); } [Test] public async Task Handle_Should_List_Only_The_Missing_Paths_When_Mixed() { await SeedLocalMediaSource(); var fileSystem = new MockFileSystem(); fileSystem.Directory.CreateDirectory("/media/movies"); CreateLocalLibraryHandler handler = CreateHandler(fileSystem); Either result = await handler.Handle( new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies", "/media/missing"]), CancellationToken.None); result.IsLeft.ShouldBeTrue(); result.IfLeft(error => { error.Value.ShouldContain("/media/missing"); error.Value.ShouldNotContain("/media/movies"); }); } [Test] public async Task Handle_Should_Create_When_All_New_Paths_Exist() { await SeedLocalMediaSource(); var fileSystem = new MockFileSystem(); fileSystem.Directory.CreateDirectory("/media/movies"); CreateLocalLibraryHandler handler = CreateHandler(fileSystem); Either result = await handler.Handle( new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies"]), CancellationToken.None); result.IsRight.ShouldBeTrue(); result.IfRight(vm => { vm.Name.ShouldBe("Movies"); vm.MediaKind.ShouldBe(LibraryMediaKind.Movies); }); } private CreateLocalLibraryHandler CreateHandler(IFileSystem fileSystem) => new( ThreadingChannel.CreateUnbounded().Writer, Substitute.For(), fileSystem, _db.Factory); private async Task SeedLocalMediaSource() { await using TvContext context = _db.CreateContext(); await context.LocalMediaSources.AddAsync(new LocalMediaSource()); await context.SaveChangesAsync(); } }