using System.Reflection; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Plex; 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.Plex; using ErsatzTV.Core.Plex; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using NSubstitute; using NUnit.Framework; using Shouldly; using static LanguageExt.Prelude; using Unit = LanguageExt.Unit; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class PlexMediaSourcesControllerTests { private ChannelWriter _channel = null!; private PlexMediaSourcesController _controller = null!; private IEntityLocker _entityLocker = null!; private IMediator _mediator = null!; private IPlexSecretStore _plexSecretStore = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _entityLocker = Substitute.For(); _plexSecretStore = Substitute.For(); _channel = Substitute.For>(); _controller = new PlexMediaSourcesController(_mediator, _entityLocker, _plexSecretStore, _channel); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(PlexMediaSourcesController.GetState), "GET", "/api/v1/media-sources/plex"); ShouldHaveActionRoute( nameof(PlexMediaSourcesController.StartPinFlow), "POST", "/api/v1/media-sources/plex/pin-flow"); ShouldHaveActionRoute(nameof(PlexMediaSourcesController.SignOutOfPlex), "POST", "/api/v1/media-sources/plex/sign-out"); ShouldHaveActionRoute( nameof(PlexMediaSourcesController.GetLibraries), "GET", "/api/v1/media-sources/plex/{id:int}/libraries"); ShouldHaveActionRoute( nameof(PlexMediaSourcesController.ReplaceLibraryPreferences), "PUT", "/api/v1/media-sources/plex/{id:int}/libraries"); ShouldHaveActionRoute( nameof(PlexMediaSourcesController.GetPathReplacements), "GET", "/api/v1/media-sources/plex/{id:int}/path-replacements"); ShouldHaveActionRoute( nameof(PlexMediaSourcesController.ReplacePathReplacements), "PUT", "/api/v1/media-sources/plex/{id:int}/path-replacements"); ShouldHaveActionRoute( nameof(PlexMediaSourcesController.RefreshLibraries), "POST", "/api/v1/media-sources/plex/{id:int}/refresh-libraries"); ShouldHaveActionRoute( nameof(PlexMediaSourcesController.ScanCollections), "POST", "/api/v1/media-sources/plex/{id:int}/scan-collections"); } // ----- P1 GetState ----- [Test] public async Task GetState_Should_Stamp_Authorized_Locked_And_Servers() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(3, "Server", "http://plex:32400") }); _plexSecretStore.GetUserAuthTokens() .Returns(new List { new("me@example.com", "token") }); _entityLocker.IsPlexLocked().Returns(true); RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); result.IsAuthorized.ShouldBeTrue(); result.IsLocked.ShouldBeTrue(); result.Servers.Single().Id.ShouldBe(3); result.Servers.Single().Address.ShouldBe("http://plex:32400"); } [Test] public async Task GetState_Should_Report_Unauthorized_With_No_Tokens() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List()); _plexSecretStore.GetUserAuthTokens().Returns(new List()); RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); result.IsAuthorized.ShouldBeFalse(); } // ----- P2 StartPinFlow ----- [Test] public async Task StartPinFlow_Should_Return_409_When_Lock_Held() { _entityLocker.LockPlex().Returns(false); IActionResult result = await _controller.StartPinFlow(CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task StartPinFlow_Should_Return_200_And_Hold_Lock_On_Success() { _entityLocker.LockPlex().Returns(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right("https://app.plex.tv/auth#?code=abc")); IActionResult result = await _controller.StartPinFlow(CancellationToken.None); var model = result.ShouldBeOfType().Value.ShouldBeOfType(); model.AuthUrl.ShouldBe("https://app.plex.tv/auth#?code=abc"); // the Right/200 path keeps the lock held for the background flow _entityLocker.DidNotReceive().UnlockPlex(); } [Test] public async Task StartPinFlow_Should_Compensate_Unlock_On_Left() { _entityLocker.LockPlex().Returns(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("plex.tv error"))); IActionResult result = await _controller.StartPinFlow(CancellationToken.None); result.ShouldBeOfType(); _entityLocker.Received(1).UnlockPlex(); } [Test] public async Task StartPinFlow_Should_Compensate_Unlock_On_Thrown_Dispatch() { _entityLocker.LockPlex().Returns(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns>(_ => throw new InvalidOperationException("channel closed")); await Should.ThrowAsync(() => _controller.StartPinFlow(CancellationToken.None)); _entityLocker.Received(1).UnlockPlex(); } // ----- P3 SignOut ----- [Test] public async Task SignOut_Should_Return_409_When_Lock_Held() { _entityLocker.LockPlex().Returns(false); IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task SignOut_Should_Return_204_And_Not_Release_In_Controller() { _entityLocker.LockPlex().Returns(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); // the handler is the designated releaser (finally) — the controller must not double-release _entityLocker.DidNotReceive().UnlockPlex(); } // ----- P4 GetLibraries ----- [Test] public async Task GetLibraries_Should_Return_404_When_Source_Missing() { SourceExists(false); IActionResult result = await _controller.GetLibraries(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetLibraries_Should_Return_200_List() { SourceExists(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); IActionResult result = await _controller.GetLibraries(3, CancellationToken.None); var list = result.ShouldBeOfType().Value.ShouldBeOfType>(); list.Single().Id.ShouldBe(5); list.Single().ShouldSyncItems.ShouldBeTrue(); } // ----- P5 ReplaceLibraryPreferences ----- [Test] public async Task ReplaceLibraries_Should_Return_404_When_Source_Missing() { SourceExists(false); IActionResult result = await _controller.ReplaceLibraryPreferences( 9, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceLibraries_Should_Return_422_When_Id_Not_Owned() { SourceExists(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); IActionResult result = await _controller.ReplaceLibraryPreferences( 3, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceLibraries_Should_Return_422_For_Zero_Id_Row() { SourceExists(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); IActionResult result = await _controller.ReplaceLibraryPreferences( 3, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(0, true)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceLibraries_Should_Return_422_When_An_Owned_Library_Is_Missing() { SourceExists(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true), new(6, "Shows", LibraryMediaKind.Shows, true) }); // request omits owned id 6 -> 422: the PUT is a complete flag document (§C4a), matching Jellyfin/Emby IActionResult result = await _controller.ReplaceLibraryPreferences( 3, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceLibraries_Should_Save_Enqueue_Ordered_Pair_And_Return_Reloaded() { SourceExists(true); // owned set for validation, then the reloaded set (drives the sync loop + the response) _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(5, "Shows", LibraryMediaKind.Shows, true) }); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _entityLocker.LockLibrary(5).Returns(true); IActionResult result = await _controller.ReplaceLibraryPreferences( 3, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), CancellationToken.None); var list = result.ShouldBeOfType().Value.ShouldBeOfType>(); list.Single().Id.ShouldBe(5); // library message carries Unlock:false; the networks message carries the single release Unlock:true Received.InOrder(() => { _channel.WriteAsync( Arg.Is(s => s.PlexLibraryId == 5 && !s.Unlock), Arg.Any()); _channel.WriteAsync( Arg.Is(n => n.PlexLibraryId == 5 && n.Unlock), Arg.Any()); }); } [Test] public async Task ReplaceLibraries_Should_Skip_Enqueue_For_Locked_Library() { SourceExists(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(5, "Shows", LibraryMediaKind.Shows, true) }); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _entityLocker.LockLibrary(5).Returns(false); IActionResult result = await _controller.ReplaceLibraryPreferences( 3, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), CancellationToken.None); result.ShouldBeOfType(); await _channel.DidNotReceive().WriteAsync( Arg.Any(), Arg.Any()); } // ----- P6 GetPathReplacements ----- [Test] public async Task GetPathReplacements_Should_Return_404_When_Source_Missing() { SourceExists(false); IActionResult result = await _controller.GetPathReplacements(9, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetPathReplacements_Should_Map_Plex_Path_To_Remote_Path() { SourceExists(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(7, "/plex", "/local") }); IActionResult result = await _controller.GetPathReplacements(3, CancellationToken.None); var list = result.ShouldBeOfType().Value .ShouldBeOfType>(); list.Single().RemotePath.ShouldBe("/plex"); list.Single().LocalPath.ShouldBe("/local"); } // ----- P7 ReplacePathReplacements ----- [Test] public async Task ReplacePathReplacements_Should_Return_404_When_Source_Missing() { SourceExists(false); IActionResult result = await _controller.ReplacePathReplacements( 9, new ReplacePathReplacementsRequest([new PathReplacementItemRequest(0, "/remote", "/local")]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplacePathReplacements_Should_Return_422_On_Handler_Left() { SourceExists(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("cross-source"))); IActionResult result = await _controller.ReplacePathReplacements( 3, new ReplacePathReplacementsRequest([new PathReplacementItemRequest(99, "/remote", "/local")]), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task ReplacePathReplacements_Should_Return_200_Reloaded_On_Success() { SourceExists(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(7, "/plex", "/local") }); IActionResult result = await _controller.ReplacePathReplacements( 3, new ReplacePathReplacementsRequest([new PathReplacementItemRequest(7, "/plex", "/local")]), CancellationToken.None); var list = result.ShouldBeOfType().Value .ShouldBeOfType>(); list.Single().Id.ShouldBe(7); } // ----- P8 RefreshLibraries ----- [Test] public async Task RefreshLibraries_Should_Return_404_When_Source_Missing() { SourceExists(false); IActionResult result = await _controller.RefreshLibraries(9, CancellationToken.None); result.ShouldBeOfType(); await _channel.DidNotReceive().WriteAsync( Arg.Any(), Arg.Any()); } [Test] public async Task RefreshLibraries_Should_Return_409_When_Plex_Locked() { SourceExists(true); _entityLocker.IsPlexLocked().Returns(true); IActionResult result = await _controller.RefreshLibraries(3, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); await _channel.DidNotReceive().WriteAsync( Arg.Any(), Arg.Any()); } [Test] public async Task RefreshLibraries_Should_Return_202_And_Enqueue() { SourceExists(true); _entityLocker.IsPlexLocked().Returns(false); IActionResult result = await _controller.RefreshLibraries(3, CancellationToken.None); result.ShouldBeOfType(); await _channel.Received(1).WriteAsync( Arg.Is(s => s.PlexMediaSourceId == 3), Arg.Any()); } // ----- P9 ScanCollections ----- [Test] public async Task ScanCollections_Should_Return_404_When_Source_Missing() { SourceExists(false); IActionResult result = await _controller.ScanCollections(9, cancellationToken: CancellationToken.None); result.ShouldBeOfType(); _entityLocker.DidNotReceive().LockPlexCollections(); await _channel.DidNotReceive().WriteAsync( Arg.Any(), Arg.Any()); } [Test] public async Task ScanCollections_Should_Return_409_When_Collections_Locked() { SourceExists(true); _entityLocker.LockPlexCollections().Returns(false); IActionResult result = await _controller.ScanCollections(3, cancellationToken: CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); await _channel.DidNotReceive().WriteAsync( Arg.Any(), Arg.Any()); } [Test] public async Task ScanCollections_Should_Return_202_And_Enqueue() { SourceExists(true); _entityLocker.LockPlexCollections().Returns(true); IActionResult result = await _controller.ScanCollections(3, deep: true, CancellationToken.None); result.ShouldBeOfType(); await _channel.Received(1).WriteAsync( Arg.Is(s => s.PlexMediaSourceId == 3 && s.ForceScan && s.DeepScan), Arg.Any()); } [Test] public async Task ScanCollections_Should_Compensate_Unlock_When_Enqueue_Throws() { SourceExists(true); _entityLocker.LockPlexCollections().Returns(true); _channel.WriteAsync(Arg.Any(), Arg.Any()) .Returns(_ => throw new InvalidOperationException("channel closed")); await Should.ThrowAsync( () => _controller.ScanCollections(3, cancellationToken: CancellationToken.None)); _entityLocker.Received(1).UnlockPlexCollections(); } private void SourceExists(bool exists) => _mediator.Send(Arg.Any(), Arg.Any()) .Returns(exists ? Option.Some(new PlexMediaSourceViewModel(3, "Server", "http://plex:32400")) : Option.None); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(PlexMediaSourcesController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }