using System.Reflection; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Emby; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.MediaSources; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Emby; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Locking; 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 EmbyMediaSourcesControllerTests { private IMediator _mediator = null!; private IEntityLocker _entityLocker = null!; private Channel _scannerChannel = null!; private EmbyMediaSourcesController _controller = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _entityLocker = Substitute.For(); _scannerChannel = System.Threading.Channels.Channel.CreateUnbounded(); _controller = new EmbyMediaSourcesController(_mediator, _entityLocker, _scannerChannel.Writer); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(EmbyMediaSourcesController.GetState), "GET", "/api/v1/media-sources/emby"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.GetConnection), "GET", "/api/v1/media-sources/emby/connection"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.SaveConnection), "PUT", "/api/v1/media-sources/emby/connection"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.Disconnect), "POST", "/api/v1/media-sources/emby/disconnect"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.GetLibraries), "GET", "/api/v1/media-sources/emby/{id:int}/libraries"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.ReplaceLibraryPreferences), "PUT", "/api/v1/media-sources/emby/{id:int}/libraries"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.GetPathReplacements), "GET", "/api/v1/media-sources/emby/{id:int}/path-replacements"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.ReplacePathReplacements), "PUT", "/api/v1/media-sources/emby/{id:int}/path-replacements"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.RefreshLibraries), "POST", "/api/v1/media-sources/emby/{id:int}/refresh-libraries"); ShouldHaveActionRoute( nameof(EmbyMediaSourcesController.ScanCollections), "POST", "/api/v1/media-sources/emby/{id:int}/scan-collections"); } [Test] public async Task GetState_Should_Report_Authorized_And_Locked() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns([new EmbyMediaSourceViewModel(1, "My Server", "http://emby.local")]); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "secret" }); _entityLocker.IsRemoteMediaSourceLocked().Returns(true); RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); result.IsAuthorized.ShouldBeTrue(); result.IsLocked.ShouldBeTrue(); result.Servers.ShouldBe([new RemoteMediaSourceItemResponseModel(1, "My Server", "http://emby.local")]); } [Test] public async Task GetState_Should_Report_Unauthorized_When_ApiKey_Blank() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List()); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "" }); RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); result.IsAuthorized.ShouldBeFalse(); } [Test] public async Task GetConnection_Should_Never_Return_The_Api_Key() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "super-secret" }); RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); result.Address.ShouldBe("http://emby.local"); result.HasApiKey.ShouldBeTrue(); result.ToString().ShouldNotContain("super-secret"); } [Test] public async Task GetConnection_Should_Report_No_Key_When_Not_Configured() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new EmbySecrets { Address = "", ApiKey = "" }); RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); result.HasApiKey.ShouldBeFalse(); } [Test] public async Task SaveConnection_Should_Return_409_When_Locked() { _entityLocker.IsRemoteMediaSourceLocked().Returns(true); IActionResult result = await _controller.SaveConnection( new SaveRemoteConnectionRequest("http://emby.local", "key"), CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task SaveConnection_Should_Return_422_For_Non_Absolute_Address() { IActionResult result = await _controller.SaveConnection( new SaveRemoteConnectionRequest("not-a-uri", "key"), CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(422); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task SaveConnection_Should_Return_422_When_Key_Blank_On_First_Connect() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new EmbySecrets { Address = "", ApiKey = "" }); IActionResult result = await _controller.SaveConnection( new SaveRemoteConnectionRequest("http://emby.local", ""), CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(422); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task SaveConnection_Should_Retain_Existing_Key_When_Blank() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns( new EmbySecrets { Address = "http://old.local", ApiKey = "existing-key" }, new EmbySecrets { Address = "http://emby.local", ApiKey = "existing-key" }); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.SaveConnection( new SaveRemoteConnectionRequest("http://emby.local", " "), CancellationToken.None); result.ShouldBeOfType().Value .ShouldBeOfType() .HasApiKey.ShouldBeTrue(); await _mediator.Received(1).Send( Arg.Is(c => c.Secrets.ApiKey == "existing-key" && c.Secrets.Address == "http://emby.local"), Arg.Any()); } [Test] public async Task SaveConnection_Should_Set_New_Key_When_NonBlank() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns( new EmbySecrets { Address = "http://old.local", ApiKey = "existing-key" }, new EmbySecrets { Address = "http://emby.local", ApiKey = "new-key" }); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.SaveConnection( new SaveRemoteConnectionRequest("http://emby.local", "new-key"), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.Secrets.ApiKey == "new-key"), Arg.Any()); } [Test] public async Task Disconnect_Should_Return_409_When_Lock_Fails() { _entityLocker.LockRemoteMediaSource().Returns(false); IActionResult result = await _controller.Disconnect(CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Disconnect_Should_Return_204_On_Success() { _entityLocker.LockRemoteMediaSource().Returns(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.Disconnect(CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetLibraries_Should_Return_404_When_Source_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetLibraries(99, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task ReplaceLibraryPreferences_Should_Return_422_For_Foreign_Id() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); IActionResult result = await _controller.ReplaceLibraryPreferences( 1, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]), CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(422); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceLibraryPreferences_Should_Return_422_When_Not_Covering_All_Libraries() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns( [ new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1), new EmbyLibraryViewModel(2, "Shows", LibraryMediaKind.Shows, true, 1) ]); IActionResult result = await _controller.ReplaceLibraryPreferences( 1, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(422); } [Test] public async Task ReplaceLibraryPreferences_Should_Enqueue_Sync_Pair_For_Enabled_Libraries_And_Return_Reloaded() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns( [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _entityLocker.LockLibrary(1).Returns(true); IActionResult result = await _controller.ReplaceLibraryPreferences( 1, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), CancellationToken.None); result.ShouldBeOfType().Value .ShouldBeOfType>() .Single().Id.ShouldBe(1); _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue(); first.ShouldBeOfType().EmbyMediaSourceId.ShouldBe(1); _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue(); second.ShouldBeOfType().EmbyLibraryId.ShouldBe(1); } [Test] public async Task ReplaceLibraryPreferences_Should_Skip_Enqueue_When_Library_Locked() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns( [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _entityLocker.LockLibrary(1).Returns(false); await _controller.ReplaceLibraryPreferences( 1, new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), CancellationToken.None); _scannerChannel.Reader.TryRead(out _).ShouldBeFalse(); } [Test] public async Task GetPathReplacements_Should_Return_404_When_Source_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetPathReplacements(99, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task ReplacePathReplacements_Should_Return_200_With_Reloaded_List() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([new EmbyPathReplacementViewModel(1, "/emby", "/local")]); IActionResult result = await _controller.ReplacePathReplacements( 1, new ReplacePathReplacementsRequest([new PathReplacementItemRequest(1, "/emby", "/local")]), CancellationToken.None); List body = result.ShouldBeOfType().Value .ShouldBeOfType>(); body.Single().ShouldBe(new PathReplacementResponseModel(1, "/emby", "/local")); } [Test] public async Task ReplacePathReplacements_Should_Map_Handler_Error_To_422() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad row"))); IActionResult result = await _controller.ReplacePathReplacements( 1, new ReplacePathReplacementsRequest([new PathReplacementItemRequest(999, "", "")]), CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(422); } [Test] public async Task RefreshLibraries_Should_Return_404_When_Source_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.RefreshLibraries(99, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task RefreshLibraries_Should_Return_409_When_Locked() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _entityLocker.IsRemoteMediaSourceLocked().Returns(true); IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(409); } [Test] public async Task RefreshLibraries_Should_Enqueue_And_Return_202() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _entityLocker.IsRemoteMediaSourceLocked().Returns(false); IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); result.ShouldBeOfType(); _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue(); request.ShouldBeOfType().EmbyMediaSourceId.ShouldBe(1); } [Test] public async Task ScanCollections_Should_Return_404_When_Source_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.ScanCollections(99, cancellationToken: CancellationToken.None); result.ShouldBeOfType(); _entityLocker.DidNotReceive().LockEmbyCollections(); } [Test] public async Task ScanCollections_Should_Return_409_When_Collections_Locked() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _entityLocker.LockEmbyCollections().Returns(false); IActionResult result = await _controller.ScanCollections(1, cancellationToken: CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); _scannerChannel.Reader.TryRead(out _).ShouldBeFalse(); } [Test] public async Task ScanCollections_Should_Enqueue_And_Return_202() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); _entityLocker.LockEmbyCollections().Returns(true); IActionResult result = await _controller.ScanCollections(1, deep: true, CancellationToken.None); result.ShouldBeOfType(); _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue(); var command = request.ShouldBeOfType(); command.EmbyMediaSourceId.ShouldBe(1); command.ForceScan.ShouldBeTrue(); command.DeepScan.ShouldBeTrue(); } private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(EmbyMediaSourcesController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }