using System.Reflection; using ErsatzTV.Application.Libraries; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.MediaSources; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using NSubstitute; using NUnit.Framework; using Shouldly; using Testably.Abstractions.Testing; using Unit = LanguageExt.Unit; using static LanguageExt.Prelude; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class LocalLibrariesControllerTests { private LocalLibrariesController _controller = null!; private IEntityLocker _entityLocker = null!; private MockFileSystem _fileSystem = null!; private ILibraryRepository _libraryRepository = null!; private IMediator _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _entityLocker = Substitute.For(); _fileSystem = new MockFileSystem(); _libraryRepository = Substitute.For(); _controller = new LocalLibrariesController(_mediator, _entityLocker, _fileSystem, _libraryRepository); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(LocalLibrariesController.GetAll), "GET", "/api/libraries/local"); ShouldHaveActionRoute(nameof(LocalLibrariesController.GetById), "GET", "/api/libraries/local/{id:int}"); ShouldHaveActionRoute(nameof(LocalLibrariesController.Create), "POST", "/api/libraries/local"); ShouldHaveActionRoute(nameof(LocalLibrariesController.Update), "PUT", "/api/libraries/local/{id:int}"); ShouldHaveActionRoute(nameof(LocalLibrariesController.Delete), "DELETE", "/api/libraries/local/{id:int}"); ShouldHaveActionRoute( nameof(LocalLibrariesController.MovePath), "POST", "/api/libraries/local/paths/{pathId:int}/move"); ShouldHaveActionRoute( nameof(LocalLibrariesController.CheckPathExists), "POST", "/api/libraries/local/path-exists"); } [Test] public async Task GetAll_Should_Project_Libraries_And_Lock_State() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(1, "Movies", LibraryMediaKind.Movies, 0), new(2, "TV Shows", LibraryMediaKind.Shows, 0) }); _entityLocker.IsLibraryLocked(1).Returns(true); _entityLocker.IsLibraryLocked(2).Returns(false); List result = await _controller.GetAll(CancellationToken.None); result.Count.ShouldBe(2); result[0].ShouldBe(new LocalLibraryResponseModel(1, "Movies", LibraryMediaKind.Movies, true)); result[1].ShouldBe(new LocalLibraryResponseModel(2, "TV Shows", LibraryMediaKind.Shows, false)); } [Test] public async Task GetById_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetById(99, CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); } [Test] public async Task GetById_Should_Return_Detail_With_Paths_And_Counts() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(10, 3, "/media/movies") }); _mediator.Send(Arg.Any(), Arg.Any()).Returns(5); _mediator.Send(Arg.Any(), Arg.Any()).Returns(5); _entityLocker.IsLibraryLocked(3).Returns(false); IActionResult result = await _controller.GetById(3, CancellationToken.None); var ok = result.ShouldBeOfType(); var detail = ok.Value.ShouldBeOfType(); detail.Id.ShouldBe(3); detail.MediaItemCount.ShouldBe(5); detail.Paths.Count.ShouldBe(1); detail.Paths[0].ShouldBe(new LocalLibraryPathResponseModel(10, "/media/movies", 5)); } [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right( new LocalLibraryViewModel(5, "Movies", LibraryMediaKind.Movies, 0))); IActionResult result = await _controller.Create( new CreateLocalLibraryRequest("Movies", LibraryMediaKind.Movies, ["/media/movies"]), CancellationToken.None); var created = result.ShouldBeOfType(); created.Location.ShouldBe("/api/libraries/local/5"); created.Value.ShouldBeOfType().Name.ShouldBe("Movies"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Movies" && c.MediaKind == LibraryMediaKind.Movies), Arg.Any()); } [Test] public async Task Create_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.Create( new CreateLocalLibraryRequest(string.Empty, LibraryMediaKind.Movies, []), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Update_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Update( 99, new UpdateLocalLibraryRequest("New Name", []), CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Update_Should_Return_409_When_Locked() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); _entityLocker.IsLibraryLocked(3).Returns(true); IActionResult result = await _controller.Update( 3, new UpdateLocalLibraryRequest("New Name", []), CancellationToken.None); var conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status409Conflict); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Update_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); _entityLocker.IsLibraryLocked(3).Returns(false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.Update( 3, new UpdateLocalLibraryRequest("New Name", []), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Update_Should_Reload_And_Return_Detail_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns( Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0)), Some(new LocalLibraryViewModel(3, "New Name", LibraryMediaKind.Movies, 0))); _entityLocker.IsLibraryLocked(3).Returns(false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right( new LocalLibraryViewModel(3, "New Name", LibraryMediaKind.Movies, 0))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List()); _mediator.Send(Arg.Any(), Arg.Any()).Returns(0); IActionResult result = await _controller.Update( 3, new UpdateLocalLibraryRequest("New Name", []), CancellationToken.None); var ok = result.ShouldBeOfType(); ok.Value.ShouldBeOfType().Name.ShouldBe("New Name"); await _mediator.Received(1).Send( Arg.Is(c => c.Id == 3 && c.Name == "New Name"), Arg.Any()); } [Test] public async Task Delete_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Delete(99, CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Delete_Should_Return_409_When_Locked() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); _entityLocker.IsLibraryLocked(3).Returns(true); IActionResult result = await _controller.Delete(3, CancellationToken.None); var conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status409Conflict); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Delete_Should_Return_204_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); _entityLocker.IsLibraryLocked(3).Returns(false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.Delete(3, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.LocalLibraryId == 3), Arg.Any()); } [Test] public async Task MovePath_Should_Return_404_When_Path_Unknown() { _libraryRepository.GetLibraryIdForPath(999).Returns(Option.None); IActionResult result = await _controller.MovePath( 999, new MoveLocalLibraryPathRequest(2), CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task MovePath_Should_Return_409_When_Source_Library_Locked() { _libraryRepository.GetLibraryIdForPath(10).Returns(Some(1)); _entityLocker.IsLibraryLocked(1).Returns(true); IActionResult result = await _controller.MovePath( 10, new MoveLocalLibraryPathRequest(2), CancellationToken.None); var conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status409Conflict); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task MovePath_Should_Return_204_On_Success() { _libraryRepository.GetLibraryIdForPath(10).Returns(Some(1)); _entityLocker.IsLibraryLocked(1).Returns(false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.MovePath( 10, new MoveLocalLibraryPathRequest(2), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.LibraryPathId == 10 && c.TargetLibraryId == 2), Arg.Any()); } [Test] public async Task MovePath_Should_Return_422_When_Handler_Rejects_Move_After_PreCheck_Passes() { // documents the residual check-then-act race (design #202 §C5): the pre-check passed, but // the handler's own validation (e.g. same-kind/different-library) still fails 422, not 404. _libraryRepository.GetLibraryIdForPath(10).Returns(Some(1)); _entityLocker.IsLibraryLocked(1).Returns(false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("Target library must be different from the source path's current library"))); IActionResult result = await _controller.MovePath( 10, new MoveLocalLibraryPathRequest(1), CancellationToken.None); result.ShouldBeOfType(); } [Test] public void CheckPathExists_Should_Return_True_When_Directory_Exists() { _fileSystem.Directory.CreateDirectory("/media/movies"); IActionResult result = _controller.CheckPathExists(new LocalPathCheckRequest("/media/movies")); var ok = result.ShouldBeOfType(); ok.Value.ShouldBeOfType().Exists.ShouldBeTrue(); } [Test] public void CheckPathExists_Should_Return_False_When_Directory_Missing() { IActionResult result = _controller.CheckPathExists(new LocalPathCheckRequest("/media/does-not-exist")); var ok = result.ShouldBeOfType(); ok.Value.ShouldBeOfType().Exists.ShouldBeFalse(); } private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(LocalLibrariesController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }