using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Trakt; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Locking; using LanguageExt; using static LanguageExt.Prelude; using MediatR; using Microsoft.AspNetCore.Mvc; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class TraktControllerTests { private IMediator _mediator = null!; private IEntityLocker _entityLocker = null!; private Channel _workerChannel = null!; private TraktController _controller = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _entityLocker = Substitute.For(); _workerChannel = Channel.CreateUnbounded(); _controller = new TraktController(_mediator, _workerChannel.Writer, _entityLocker); } [Test] public async Task GetAll_Should_Clamp_Paging_And_Project_Response() { var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedTraktListsViewModel(1, [vm])); PagedTraktListsResponseModel result = await _controller.GetAll(-5, 500, CancellationToken.None); result.TotalCount.ShouldBe(1); result.Page.ShouldBe( [new TraktListResponseModel(1, 100, "my-list", "My List", 10, 8, true, false)]); await _mediator.Received(1).Send( Arg.Is(q => q.PageNum == 0 && q.PageSize == 100), Arg.Any()); } [Test] public async Task GetById_Should_Return_200_For_Some() { var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.GetById(1, CancellationToken.None); result.ShouldBeOfType().Value .ShouldBe(new TraktListResponseModel(1, 100, "my-list", "My List", 10, 8, true, false)); } [Test] public async Task GetById_Should_Return_404_For_None() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetById(99, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problemDetails = notFound.Value.ShouldBeOfType(); problemDetails.Status.ShouldBe(404); } [Test] public async Task Add_Should_Return_422_For_Invalid_Url() { IActionResult result = await _controller.Add( new AddTraktListRequest("not-a-trakt-url ?? "), CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(422); _entityLocker.DidNotReceive().LockTrakt(); } [TestCase("https://trakt.tv/users/someuser/lists/some-list")] [TestCase("https://app.trakt.tv/users/someuser/some-list")] [TestCase("https://trakt.tv/lists/someuser/some-list")] [TestCase("someuser/lists/some-list")] [TestCase("someuser/some-list")] public async Task Add_Should_Accept_Known_Trakt_Url_Shapes_And_Dispatch(string url) { _entityLocker.LockTrakt().Returns(true); IActionResult result = await _controller.Add(new AddTraktListRequest(url), CancellationToken.None); result.ShouldBeOfType(); _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); request.ShouldBeOfType().TraktListUrl.ShouldBe(url); } [Test] public async Task Add_Should_Return_409_When_Already_Locked() { _entityLocker.LockTrakt().Returns(false); IActionResult result = await _controller.Add( new AddTraktListRequest("https://trakt.tv/users/someuser/lists/some-list"), CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(409); _workerChannel.Reader.TryRead(out _).ShouldBeFalse(); } [Test] public async Task Match_Should_Return_404_When_List_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Match(99, CancellationToken.None); result.ShouldBeOfType(); _entityLocker.DidNotReceive().LockTrakt(); } [Test] public async Task Match_Should_Dispatch_And_Return_202() { var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _entityLocker.LockTrakt().Returns(true); IActionResult result = await _controller.Match(1, CancellationToken.None); result.ShouldBeOfType(); _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); request.ShouldBeOfType().TraktListId.ShouldBe(1); } [Test] public async Task Match_Should_Return_409_When_Already_Locked() { var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _entityLocker.LockTrakt().Returns(false); IActionResult result = await _controller.Match(1, CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(409); } [Test] public async Task Delete_Should_Return_404_When_List_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Delete(99, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Delete_Should_Dispatch_And_Return_202() { var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _entityLocker.LockTrakt().Returns(true); IActionResult result = await _controller.Delete(1, CancellationToken.None); result.ShouldBeOfType(); _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); request.ShouldBeOfType().TraktListId.ShouldBe(1); } [Test] public async Task Update_Should_Return_404_When_List_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Update( 99, new UpdateTraktListRequest(true, false), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Update_Should_Return_200_With_Updated_List() { var before = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, false, false); var after = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(before), Option.Some(after)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Update( 1, new UpdateTraktListRequest(true, true), CancellationToken.None); result.ShouldBeOfType().Value .ShouldBe(new TraktListResponseModel(1, 100, "my-list", "My List", 10, 8, true, true)); await _mediator.Received(1).Send( Arg.Is(c => c.Id == 1 && c.AutoRefresh && c.GeneratePlaylist), Arg.Any()); } [Test] public async Task Update_Should_Return_422_For_Error() { var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, false, false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(BaseError.New("bad"))); IActionResult result = await _controller.Update( 1, new UpdateTraktListRequest(true, true), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Update_Should_Return_404_For_NotFoundError() { var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, false, false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new NotFoundError("missing"))); IActionResult result = await _controller.Update( 1, new UpdateTraktListRequest(true, true), CancellationToken.None); result.ShouldBeOfType(); } [TestCase(true)] [TestCase(false)] public void GetStatus_Should_Wrap_IsTraktLocked(bool busy) { _entityLocker.IsTraktLocked().Returns(busy); TraktStatusResponseModel result = _controller.GetStatus(); result.Busy.ShouldBe(busy); } }