using System.Reflection; using ErsatzTV.Application.Scheduling; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Scheduling; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using NSubstitute; using NUnit.Framework; using Shouldly; using static LanguageExt.Prelude; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class DecoControllerTests { private DecoController _controller = null!; private IMediator _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _controller = new DecoController(_mediator); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(DecoController.GetGroups), "GET", "/api/v1/decos/groups"); ShouldHaveActionRoute(nameof(DecoController.CreateGroup), "POST", "/api/v1/decos/groups"); ShouldHaveActionRoute(nameof(DecoController.DeleteGroup), "DELETE", "/api/v1/decos/groups/{id:int}"); ShouldHaveActionRoute(nameof(DecoController.GetAll), "GET", "/api/v1/decos"); ShouldHaveActionRoute(nameof(DecoController.GetById), "GET", "/api/v1/decos/{id:int}"); ShouldHaveActionRoute(nameof(DecoController.Create), "POST", "/api/v1/decos"); ShouldHaveActionRoute(nameof(DecoController.Delete), "DELETE", "/api/v1/decos/{id:int}"); ShouldHaveActionRoute(nameof(DecoController.Replace), "PUT", "/api/v1/decos/{id:int}"); } [Test] public async Task GetGroups_Should_Map_ViewModels() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(3, "Bumpers", 2) }); List result = await _controller.GetGroups(CancellationToken.None); result.Count.ShouldBe(1); result[0].Id.ShouldBe(3); result[0].Name.ShouldBe("Bumpers"); result[0].DecoCount.ShouldBe(2); } [Test] public async Task CreateGroup_Should_Return_201_With_Location_And_Body() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new DecoGroupViewModel(5, "Bumpers", 0))); IActionResult result = await _controller.CreateGroup( new CreateDecoGroupRequest("Bumpers"), CancellationToken.None); var created = result.ShouldBeOfType(); created.Location.ShouldBe("/api/v1/decos/groups/5"); created.Value.ShouldBeOfType().Name.ShouldBe("Bumpers"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Bumpers"), Arg.Any()); } [Test] public async Task CreateGroup_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.CreateGroup( new CreateDecoGroupRequest(string.Empty), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task DeleteGroup_Should_Return_204_When_Exists() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(3, "Bumpers", 0) }); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.DeleteGroup(3, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.DecoGroupId == 3), Arg.Any()); } [Test] public async Task DeleteGroup_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List()); IActionResult result = await _controller.DeleteGroup(99, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetAll_Should_Flatten_Decos_Across_Groups() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(1, "A", 1), new(2, "B", 1) }); _mediator.Send(Arg.Is(q => q.DecoGroupId == 1), Arg.Any()) .Returns(new List { MakeDeco(10, 1, "First") }); _mediator.Send(Arg.Is(q => q.DecoGroupId == 2), Arg.Any()) .Returns(new List { MakeDeco(20, 2, "Second") }); List result = await _controller.GetAll(CancellationToken.None); result.Count.ShouldBe(2); result[0].Id.ShouldBe(10); result[0].DecoGroupId.ShouldBe(1); result[1].Id.ShouldBe(20); } [Test] public async Task GetById_Should_Return_200_And_Map_All_Sections() { DecoViewModel deco = MakeDeco( 4, 2, "Movie Night", watermarkMode: DecoMode.Merge, breakContentMode: DecoMode.Override, breakContent: [ new DecoBreakContentViewModel( 7, CollectionType.Playlist, null, null, null, null, new ErsatzTV.Application.MediaCollections.PlaylistViewModel(9, 3, "Idents", false, 1), DecoBreakPlacement.BlockStart) ]); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(deco)); IActionResult result = await _controller.GetById(4, CancellationToken.None); var body = result.ShouldBeOfType().Value.ShouldBeOfType(); body.Id.ShouldBe(4); body.WatermarkMode.ShouldBe(DecoMode.Merge); body.BreakContentMode.ShouldBe(DecoMode.Override); body.BreakContent.Count.ShouldBe(1); body.BreakContent[0].PlaylistId.ShouldBe(9); body.BreakContent[0].PlaylistGroupId.ShouldBe(3); body.BreakContent[0].SelectionName.ShouldBe("Idents"); body.BreakContent[0].Placement.ShouldBe(DecoBreakPlacement.BlockStart); } [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_Map_Request() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(MakeDeco(8, 2, "Movie Night"))); IActionResult result = await _controller.Create( new CreateDecoRequest(2, "Movie Night"), CancellationToken.None); var created = result.ShouldBeOfType(); created.Location.ShouldBe("/api/v1/decos/8"); await _mediator.Received(1).Send( Arg.Is(c => c.DecoGroupId == 2 && c.Name == "Movie Night"), Arg.Any()); } [Test] public async Task Create_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("Deco group does not exist"))); IActionResult result = await _controller.Create( new CreateDecoRequest(99, "Movie Night"), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Delete_Should_Return_204_When_Exists() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeDeco(4, 2, "Movie Night"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Delete(4, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Delete_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Delete(4, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Replace_Should_Return_200_And_Send_Full_State_With_Current_GroupId() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeDeco(4, 7, "Movie Night"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(LanguageExt.Unit.Default)); IActionResult result = await _controller.Replace( 4, MakeReplaceRequest( "Movie Night", breakContentMode: DecoMode.Override, breakContent: [ new DecoBreakContentRequest( 0, CollectionType.Playlist, null, null, null, null, 9, DecoBreakPlacement.BlockStart) ]), CancellationToken.None); var body = result.ShouldBeOfType().Value.ShouldBeOfType(); body.Id.ShouldBe(4); await _mediator.Received(1).Send( Arg.Is(c => c.DecoId == 4 && c.DecoGroupId == 7 && c.Name == "Movie Night" && c.BreakContentMode == DecoMode.Override && c.BreakContent.Count == 1 && c.BreakContent[0].PlaylistId == 9 && c.BreakContent[0].Placement == DecoBreakPlacement.BlockStart), Arg.Any()); } [Test] public async Task Replace_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Replace( 4, MakeReplaceRequest("Movie Night"), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Replace_Should_Return_422_On_Handler_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeDeco(4, 7, "Movie Night"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("Deco may only contain one [Block Start] break content"))); IActionResult result = await _controller.Replace( 4, MakeReplaceRequest("Movie Night"), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Replace_Should_Return_422_On_Merge_Mode_Mismatch_Without_Dispatch() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeDeco(4, 7, "Movie Night"))); IActionResult result = await _controller.Replace( 4, MakeReplaceRequest("Movie Night", defaultFillerMode: DecoMode.Merge), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } private static DecoViewModel MakeDeco( int id, int groupId, string name, DecoMode watermarkMode = DecoMode.Inherit, DecoMode breakContentMode = DecoMode.Inherit, List? breakContent = null) => new( id, groupId, "Group", name, watermarkMode, [], false, DecoMode.Inherit, [], false, breakContentMode, breakContent ?? [], DecoMode.Inherit, CollectionType.Collection, null, null, null, null, false, DecoMode.Inherit, CollectionType.Collection, null, null, null, null); private static ReplaceDecoRequest MakeReplaceRequest( string name, DecoMode breakContentMode = DecoMode.Inherit, DecoMode defaultFillerMode = DecoMode.Inherit, List? breakContent = null) => new( name, DecoMode.Inherit, [], false, DecoMode.Inherit, [], false, breakContentMode, breakContent ?? [], defaultFillerMode, CollectionType.Collection, null, null, null, null, false, DecoMode.Inherit, CollectionType.Collection, null, null, null, null); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(DecoController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }