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(); _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(), Arg.Any()) .Returns(Right(vm)); IActionResult result = await _controller.Create( new CreateSmartCollectionRequest("Kids", "tag:family"), CancellationToken.None); var created = result.ShouldBeOfType(); 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(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.Create( new CreateSmartCollectionRequest(string.Empty, string.Empty), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Create_Should_Map_Request_To_Command() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right( new SmartCollectionViewModel(7, "Kids", "tag:family"))); await _controller.Create(new CreateSmartCollectionRequest("Kids", "tag:family"), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Kids" && c.Query == "tag:family"), Arg.Any()); } [Test] public async Task Update_Should_Return_200_And_Map_Route_Id() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new UpdateSmartCollectionResult(8))); var vm = new SmartCollectionViewModel(8, "Updated", "tag:updated"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Update( 8, new UpdateSmartCollectionRequest("Updated", "tag:updated"), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(vm); await _mediator.Received(1).Send( Arg.Is(c => c.Id == 8 && c.Name == "Updated" && c.Query == "tag:updated"), Arg.Any()); } [Test] public async Task Update_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.Update( 99, new UpdateSmartCollectionRequest("Missing", "tag:missing"), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Delete_Should_Return_204_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.Delete(9, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Delete_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.Delete(9, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetAll_Should_Return_SmartCollections() { List vms = [ new SmartCollectionResponseModel(1, "Kids", "tag:kids"), new SmartCollectionResponseModel(2, "News", "tag:news") ]; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(vms); List 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(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.GetById(4, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(vm); } [Test] public async Task GetById_Should_Return_404_For_None() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetById(4, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problemDetails = notFound.Value.ShouldBeOfType(); 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(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }