Adds TraktController (GET/POST /api/trakt/lists, GET/PUT/DELETE by id, POST .../match, GET /api/trakt/status) covering the API gap for SPA parity issue #142. Add/match/delete dispatch to the same ChannelWriter<IBackgroundServiceRequest> the classic Blazor "Trakt Lists" page uses, gated by the existing IEntityLocker (LockTrakt/ IsTraktLocked) singleton; GET /api/trakt/status wraps IsTraktLocked as the HTTP-observable substitute for the Blazor page's OnTraktChanged event. URL validation mirrors AddTraktListHandler.ValidateUrl's regexes (replicated, since that method is private to the handler and returns a handler-private record) so an obviously-invalid URL gets a synchronous 422 before dispatch. Adds TraktListResponseModel/PagedTraktListsResponseModel/ TraktStatusResponseModel DTOs, AddTraktListRequest/UpdateTraktListRequest, controller tests, ApiControllerSecurityTests + OpenApiErrorResponseContractTests coverage, and regenerates the checked-in OpenAPI document.
268 lines
10 KiB
C#
268 lines
10 KiB
C#
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<IBackgroundServiceRequest> _workerChannel = null!;
|
|
private TraktController _controller = null!;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
_mediator = Substitute.For<IMediator>();
|
|
_entityLocker = Substitute.For<IEntityLocker>();
|
|
_workerChannel = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
|
_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<GetPagedTraktLists>(), Arg.Any<CancellationToken>())
|
|
.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<GetPagedTraktLists>(q => q.PageNum == 0 && q.PageSize == 100),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
|
|
|
IActionResult result = await _controller.GetById(1, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>().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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.None);
|
|
|
|
IActionResult result = await _controller.GetById(99, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
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<UnprocessableEntityObjectResult>().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<AcceptedResult>();
|
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
|
request.ShouldBeOfType<AddTraktList>().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<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
|
_workerChannel.Reader.TryRead(out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Match_Should_Return_404_When_List_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.None);
|
|
|
|
IActionResult result = await _controller.Match(99, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
_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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
|
_entityLocker.LockTrakt().Returns(true);
|
|
|
|
IActionResult result = await _controller.Match(1, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<AcceptedResult>();
|
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
|
request.ShouldBeOfType<MatchTraktListItems>().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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
|
_entityLocker.LockTrakt().Returns(false);
|
|
|
|
IActionResult result = await _controller.Match(1, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Delete_Should_Return_404_When_List_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.None);
|
|
|
|
IActionResult result = await _controller.Delete(99, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
|
_entityLocker.LockTrakt().Returns(true);
|
|
|
|
IActionResult result = await _controller.Delete(1, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<AcceptedResult>();
|
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
|
request.ShouldBeOfType<DeleteTraktList>().TraktListId.ShouldBe(1);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Return_404_When_List_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.None);
|
|
|
|
IActionResult result = await _controller.Update(
|
|
99,
|
|
new UpdateTraktListRequest(true, false),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<UpdateTraktList>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.Some(before), Option<TraktListViewModel>.Some(after));
|
|
_mediator.Send(Arg.Any<UpdateTraktList>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<BaseError>.None);
|
|
|
|
IActionResult result = await _controller.Update(
|
|
1,
|
|
new UpdateTraktListRequest(true, true),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>().Value
|
|
.ShouldBe(new TraktListResponseModel(1, 100, "my-list", "My List", 10, 8, true, true));
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<UpdateTraktList>(c => c.Id == 1 && c.AutoRefresh && c.GeneratePlaylist),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
|
_mediator.Send(Arg.Any<UpdateTraktList>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
|
|
|
|
IActionResult result = await _controller.Update(
|
|
1,
|
|
new UpdateTraktListRequest(true, true),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
}
|
|
|
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
|
_mediator.Send(Arg.Any<UpdateTraktList>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<BaseError>.Some(new NotFoundError("missing")));
|
|
|
|
IActionResult result = await _controller.Update(
|
|
1,
|
|
new UpdateTraktListRequest(true, true),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[TestCase(true)]
|
|
[TestCase(false)]
|
|
public void GetStatus_Should_Wrap_IsTraktLocked(bool busy)
|
|
{
|
|
_entityLocker.IsTraktLocked().Returns(busy);
|
|
|
|
TraktStatusResponseModel result = _controller.GetStatus();
|
|
|
|
result.Busy.ShouldBe(busy);
|
|
}
|
|
}
|