Files
ersatztv/ErsatzTV.Tests/Controllers/TraktControllerTests.cs
T
timothy 4306b39ef8 feat(api): Trakt lists REST endpoints (#160)
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.
2026-07-07 15:09:12 +02:00

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);
}
}