using System.Reflection; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.MediaCollections; using ErsatzTV.Core.Domain; 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 RerunCollectionControllerTests { [SetUp] public void SetUp() { _mediator = Substitute.For(); _controller = new RerunCollectionController(_mediator) { // Real HttpContext so the #253 ETag/If-Match concurrency headers can be read/written. ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; } private RerunCollectionController _controller = null!; private IMediator _mediator = null!; [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(RerunCollectionController.GetAll), "GET", "/api/v1/rerun-collections"); ShouldHaveActionRoute(nameof(RerunCollectionController.GetById), "GET", "/api/v1/rerun-collections/{id:int}"); ShouldHaveActionRoute(nameof(RerunCollectionController.Create), "POST", "/api/v1/rerun-collections"); ShouldHaveActionRoute(nameof(RerunCollectionController.Update), "PUT", "/api/v1/rerun-collections/{id:int}"); ShouldHaveActionRoute(nameof(RerunCollectionController.Delete), "DELETE", "/api/v1/rerun-collections/{id:int}"); } [Test] public async Task GetAll_Should_Clamp_Paging_And_Map_Response() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedRerunCollectionsViewModel( 1, new List { MakeRerunFromSmart(3, "Nightly") })); PagedRerunCollectionsResponseModel result = await _controller.GetAll("n", -5, 9999, CancellationToken.None); result.TotalCount.ShouldBe(1); result.Page.Count.ShouldBe(1); result.Page[0].Id.ShouldBe(3); result.Page[0].CollectionType.ShouldBe(CollectionType.SmartCollection); result.Page[0].SelectedId.ShouldBe(20); result.Page[0].SelectedName.ShouldBe("Smart"); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "n" && q.PageNum == 0 && q.PageSize == 100), Arg.Any()); } [Test] public async Task GetById_Should_Return_200_For_Some() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeRerunFromSmart(4, "Nightly"))); IActionResult result = await _controller.GetById(4, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Id.ShouldBe(4); } [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); result.ShouldBeOfType(); } [Test] public async Task Create_Should_Return_201_And_Resolve_Selected_Id_Into_Collection_Vm() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(MakeRerunFromSmart(8, "Nightly"))); IActionResult result = await _controller.Create( new CreateRerunCollectionRequest( "Nightly", CollectionType.Collection, 42, PlaybackOrder.Chronological, PlaybackOrder.Shuffle), CancellationToken.None); var created = result.ShouldBeOfType(); created.Location.ShouldBe("/api/v1/rerun-collections/8"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Nightly" && c.CollectionType == CollectionType.Collection && c.Collection != null && c.Collection.Id == 42 && c.MultiCollection == null && c.SmartCollection == null && c.MediaItem == null && c.FirstRunPlaybackOrder == PlaybackOrder.Chronological && c.RerunPlaybackOrder == PlaybackOrder.Shuffle), Arg.Any()); } [Test] public async Task Create_Should_Resolve_Media_Item_Selection_For_Show_Type() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(MakeRerunFromSmart(9, "Nightly"))); await _controller.Create( new CreateRerunCollectionRequest( "Nightly", CollectionType.TelevisionShow, 77, PlaybackOrder.Chronological, PlaybackOrder.Chronological), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.CollectionType == CollectionType.TelevisionShow && c.Collection == null && c.MediaItem != null && c.MediaItem.MediaItemId == 77), Arg.Any()); } [Test] public async Task Create_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("dupe"))); IActionResult result = await _controller.Create( new CreateRerunCollectionRequest( "Nightly", CollectionType.SmartCollection, 1, PlaybackOrder.Chronological, PlaybackOrder.Chronological), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Create_Should_Return_422_For_Unsupported_Collection_Type() { IActionResult result = await _controller.Create( new CreateRerunCollectionRequest( "Nightly", CollectionType.Playlist, 1, PlaybackOrder.Chronological, PlaybackOrder.Chronological), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Update_Should_Return_200_With_Refreshed_Body_And_Resolve_Selection() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeRerunFromSmart(4, "Nightly"))); IActionResult result = await _controller.Update( 4, new UpdateRerunCollectionRequest( "Nightly", CollectionType.MultiCollection, 55, PlaybackOrder.Chronological, PlaybackOrder.Shuffle), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Id.ShouldBe(4); await _mediator.Received(1).Send( Arg.Is(c => c.RerunCollectionId == 4 && c.Name == "Nightly" && c.CollectionType == CollectionType.MultiCollection && c.MultiCollection != null && c.MultiCollection.Id == 55 && c.Collection == null && c.SmartCollection == null && c.MediaItem == null), Arg.Any()); } [Test] public async Task Update_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.Update( 4, new UpdateRerunCollectionRequest( "Nightly", CollectionType.SmartCollection, 1, PlaybackOrder.Chronological, PlaybackOrder.Chronological), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Update_Should_Return_422_For_Unsupported_Collection_Type() { IActionResult result = await _controller.Update( 4, new UpdateRerunCollectionRequest( "Nightly", CollectionType.RerunFirstRun, 1, PlaybackOrder.Chronological, PlaybackOrder.Chronological), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [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(4, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.RerunCollectionId == 4), Arg.Any()); } [Test] public async Task Delete_Should_Return_404_On_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new ErsatzTV.Core.Errors.NotFoundError("missing"))); IActionResult result = await _controller.Delete(4, CancellationToken.None); result.ShouldBeOfType(); } private static RerunCollectionViewModel MakeRerunFromSmart(int id, string name) => new( id, name, CollectionType.SmartCollection, null, null, new SmartCollectionViewModel(20, "Smart", "query"), null, PlaybackOrder.Chronological, PlaybackOrder.Shuffle); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(RerunCollectionController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }