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.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using Microsoft.EntityFrameworkCore; 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 UpdateLocalLibraryHandlerTests { 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() { (int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/movies"); UpdateLocalLibraryHandler handler = CreateHandler(new MockFileSystem()); Either result = await handler.Handle( new UpdateLocalLibrary( libraryId, "Movies", [ new UpdateLocalLibraryPath(pathId, "/media/movies"), new UpdateLocalLibraryPath(0, "/media/missing") ]), CancellationToken.None); result.IsLeft.ShouldBeTrue(); result.IfLeft(error => error.Value.ShouldContain("/media/missing")); } [Test] public async Task Handle_Should_Exempt_Existing_Paths_From_The_Existence_Check() { // the existing row's directory is NOT in the mock filesystem (simulates an unmounted // share) -- design #202 §C2: existing rows are exempt so a rename doesn't fail on a // temporarily-missing mount. (int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/movies"); UpdateLocalLibraryHandler handler = CreateHandler(new MockFileSystem()); Either result = await handler.Handle( new UpdateLocalLibrary( libraryId, "Movies Renamed", [new UpdateLocalLibraryPath(pathId, "/media/movies")]), CancellationToken.None); result.IsRight.ShouldBeTrue(); } [Test] public async Task Handle_Should_Identify_Paths_By_Normalized_String_Not_Id() { (int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/Movies/"); var fileSystem = new MockFileSystem(); fileSystem.Directory.CreateDirectory("/media/movies"); UpdateLocalLibraryHandler handler = CreateHandler(fileSystem); // same path, different case + no trailing slash, Id omitted (0): the handler's merge is by // normalized path string (design #202 §C4c), so this must NOT be treated as delete-old + // add-new -- the existing LibraryPath row (and its id) is preserved. Either result = await handler.Handle( new UpdateLocalLibrary(libraryId, "Movies", [new UpdateLocalLibraryPath(0, "/media/movies")]), CancellationToken.None); result.IsRight.ShouldBeTrue(); await using TvContext dbContext = _db.CreateContext(); List paths = await dbContext.LibraryPaths .Where(lp => lp.LibraryId == libraryId) .ToListAsync(); paths.Count.ShouldBe(1); paths[0].Id.ShouldBe(pathId); } [Test] public async Task Handle_Should_Add_And_Remove_Paths_By_Normalized_String_Diff() { (int libraryId, int keptPathId) = await SeedLibraryWithPath("Movies", "/media/keep"); var fileSystem = new MockFileSystem(); fileSystem.Directory.CreateDirectory("/media/new"); UpdateLocalLibraryHandler handler = CreateHandler(fileSystem); Either result = await handler.Handle( new UpdateLocalLibrary( libraryId, "Movies", [ new UpdateLocalLibraryPath(keptPathId, "/media/keep"), new UpdateLocalLibraryPath(0, "/media/new") ]), CancellationToken.None); result.IsRight.ShouldBeTrue(); await using TvContext dbContext = _db.CreateContext(); List paths = await dbContext.LibraryPaths .Where(lp => lp.LibraryId == libraryId) .Select(lp => lp.Path) .ToListAsync(); paths.Count.ShouldBe(2); paths.ShouldContain("/media/keep"); paths.ShouldContain("/media/new"); } private UpdateLocalLibraryHandler CreateHandler(IFileSystem fileSystem) => new( ThreadingChannel.CreateUnbounded().Writer, Substitute.For(), fileSystem, Substitute.For(), _db.Factory); private async Task<(int LibraryId, int PathId)> SeedLibraryWithPath(string name, string path) { await using TvContext context = _db.CreateContext(); var source = new LocalMediaSource { Libraries = [ new LocalLibrary { Name = name, MediaKind = LibraryMediaKind.Movies, Paths = [new LibraryPath { Path = path }] } ] }; await context.LocalMediaSources.AddAsync(source); await context.SaveChangesAsync(); return (source.Libraries[0].Id, source.Libraries[0].Paths[0].Id); } }