using System.Reflection; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Api.MediaCollections; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Http; 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 CollectionControllerTests { private CollectionController _controller = null!; private IMediator _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _controller = new CollectionController(_mediator) { // Real HttpContext so the #253 ETag/If-Match concurrency headers can be read/written. ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/collections"); ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/collections/{id:int}"); ShouldHaveActionRoute(nameof(CollectionController.GetItems), "GET", "/api/collections/{id:int}/items"); ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/collections"); ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/collections/{id:int}"); ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/collections/{id:int}"); ShouldHaveActionRoute(nameof(CollectionController.AddItems), "POST", "/api/collections/{id:int}/items"); ShouldHaveActionRoute( nameof(CollectionController.RemoveItem), "DELETE", "/api/collections/{id:int}/items/{mediaItemId:int}"); ShouldHaveActionRoute( nameof(CollectionController.UpdateCustomOrder), "PUT", "/api/collections/{id:int}/custom-order"); } [Test] public async Task UpdateCustomOrder_Should_Return_404_When_Collection_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.UpdateCustomOrder( 99, new UpdateCollectionCustomOrderRequest([10, 20]), CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(404); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task UpdateCustomOrder_Should_Return_204_And_Assign_Index_From_Order() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeVm(7, "Movies"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.UpdateCustomOrder( 7, new UpdateCollectionCustomOrderRequest([30, 10, 20]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.CollectionId == 7 && c.MediaItemCustomOrders.Count == 3 && c.MediaItemCustomOrders[0].MediaItemId == 30 && c.MediaItemCustomOrders[0].CustomIndex == 0 && c.MediaItemCustomOrders[1].MediaItemId == 10 && c.MediaItemCustomOrders[1].CustomIndex == 1 && c.MediaItemCustomOrders[2].MediaItemId == 20 && c.MediaItemCustomOrders[2].CustomIndex == 2), Arg.Any()); } [Test] public async Task UpdateCustomOrder_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeVm(7, "Movies"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.UpdateCustomOrder( 7, new UpdateCollectionCustomOrderRequest([10]), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { MediaCollectionViewModel vm = MakeVm(5, "Movies"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(vm)); IActionResult result = await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None); var created = result.ShouldBeOfType(); created.StatusCode.ShouldBe(201); created.Location.ShouldBe("/api/collections/5"); created.Value.ShouldBe(new MediaCollectionResponseModel(5, "Movies", CollectionType.Collection, false)); } [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 CreateCollectionRequest(string.Empty), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Create_Should_Map_Request_To_Command() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(MakeVm(5, "Movies"))); await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Movies"), Arg.Any()); } [Test] public async Task Update_Should_Return_200_And_Map_Route_Id() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); MediaCollectionViewModel vm = MakeVm(7, "Updated"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Update( 7, new UpdateCollectionRequest("Updated", true), CancellationToken.None); result.ShouldBeOfType().Value .ShouldBe(new MediaCollectionResponseModel(7, "Updated", CollectionType.Collection, false)); await _mediator.Received(1).Send( Arg.Is(c => c.CollectionId == 7 && c.Name == "Updated" && c.UseCustomPlaybackOrder.Match(Some: v => v, None: () => false)), 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 UpdateCollectionRequest("Missing", false), 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(3, 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(99, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task AddItems_Should_Return_204_And_Map_Route_Id() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.AddItems( 3, new AddItemsToCollectionRequest([10], null, null, null, null, null, null, null, null, null), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.CollectionId == 3 && c.MovieIds.SequenceEqual(new[] { 10 })), Arg.Any()); } [Test] public async Task AddItems_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.AddItems( 3, new AddItemsToCollectionRequest(null, null, null, null, null, null, null, null, null, [999]), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task RemoveItem_Should_Return_204_And_Map_Route_Ids() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.MediaCollectionId == 3 && c.MediaItemIds.SequenceEqual(new[] { 10 })), Arg.Any()); } [Test] public async Task RemoveItem_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetAll_Should_Return_Collections() { List vms = [MakeVm(1, "Movies"), MakeVm(2, "Shows")]; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(vms); List result = await _controller.GetAll(CancellationToken.None); result.ShouldBe( [ new MediaCollectionResponseModel(1, "Movies", CollectionType.Collection, false), new MediaCollectionResponseModel(2, "Shows", CollectionType.Collection, false) ]); } [Test] public async Task GetById_Should_Return_200_For_Some() { MediaCollectionViewModel vm = MakeVm(4, "Movies"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.GetById(4, CancellationToken.None); result.ShouldBeOfType().Value .ShouldBe(new MediaCollectionResponseModel(4, "Movies", CollectionType.Collection, 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(4, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problemDetails = notFound.Value.ShouldBeOfType(); problemDetails.Status.ShouldBe(404); problemDetails.Title.ShouldBe("Resource not found"); } [Test] public async Task GetItems_Should_Return_200_With_Page_And_Clamp_Paging() { var page = new PagedLibraryBrowseItemsResponseModel(0, []); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(page)); IActionResult result = await _controller.GetItems(7, -5, 999, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(page); await _mediator.Received(1).Send( Arg.Is(q => q.Id == 7 && q.PageNum == 0 && q.PageSize == 100), Arg.Any()); } [Test] public async Task GetItems_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.GetItems(99, 0, 100, CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(404); } private static MediaCollectionViewModel MakeVm(int id, string name) => new(CollectionType.Collection, id, name, false, MediaItemState.Normal); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(CollectionController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }