Files
ersatztv/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs
T
timothy 527332a3ac
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): default-deny mutating API writes
Refs #43
2026-06-30 18:49:35 +02:00

196 lines
7.4 KiB
C#

using System.Reflection;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Errors;
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 SmartCollectionControllerTests
{
private SmartCollectionController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new SmartCollectionController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(SmartCollectionController.GetAll), "GET", "/api/smart-collections");
ShouldHaveActionRoute(nameof(SmartCollectionController.GetById), "GET", "/api/smart-collections/{id:int}");
ShouldHaveActionRoute(nameof(SmartCollectionController.Create), "POST", "/api/smart-collections");
ShouldHaveActionRoute(nameof(SmartCollectionController.Update), "PUT", "/api/smart-collections/{id:int}");
ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/smart-collections/{id:int}");
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
var vm = new SmartCollectionViewModel(7, "Kids", "tag:family");
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, SmartCollectionViewModel>(vm));
IActionResult result = await _controller.Create(
new CreateSmartCollectionRequest("Kids", "tag:family"),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/smart-collections/7");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, SmartCollectionViewModel>(BaseError.New("bad")));
IActionResult result = await _controller.Create(
new CreateSmartCollectionRequest(string.Empty, string.Empty),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, SmartCollectionViewModel>(
new SmartCollectionViewModel(7, "Kids", "tag:family")));
await _controller.Create(new CreateSmartCollectionRequest("Kids", "tag:family"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateSmartCollection>(c => c.Name == "Kids" && c.Query == "tag:family"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
_mediator.Send(Arg.Any<UpdateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, UpdateSmartCollectionResult>(new UpdateSmartCollectionResult(8)));
var vm = new SmartCollectionViewModel(8, "Updated", "tag:updated");
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<SmartCollectionViewModel>.Some(vm));
IActionResult result = await _controller.Update(
8,
new UpdateSmartCollectionRequest("Updated", "tag:updated"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateSmartCollection>(c =>
c.Id == 8 && c.Name == "Updated" && c.Query == "tag:updated"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, UpdateSmartCollectionResult>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(
99,
new UpdateSmartCollectionRequest("Missing", "tag:missing"),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetAll_Should_Return_SmartCollections()
{
List<SmartCollectionResponseModel> vms =
[
new SmartCollectionResponseModel(1, "Kids", "tag:kids"),
new SmartCollectionResponseModel(2, "News", "tag:news")
];
_mediator.Send(Arg.Any<GetAllSmartCollectionsForApi>(), Arg.Any<CancellationToken>())
.Returns(vms);
List<SmartCollectionResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(vms);
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
var vm = new SmartCollectionViewModel(4, "Kids", "tag:kids");
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<SmartCollectionViewModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<SmartCollectionViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
problemDetails.Title.ShouldBe("Resource not found");
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(SmartCollectionController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}