using System.Reflection; using ErsatzTV.Application.Watermarks; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Watermarks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.FFmpeg.State; 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 WatermarkControllerTests { [SetUp] public void SetUp() { _mediator = Substitute.For(); _controller = new WatermarkController(_mediator); } private WatermarkController _controller = null!; private IMediator _mediator = null!; [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute("GET", "/api/v1/watermarks"); ShouldHaveActionRoute("GET", "/api/v1/watermarks/{id:int}"); ShouldHaveActionRoute("POST", "/api/v1/watermarks"); ShouldHaveActionRoute("PUT", "/api/v1/watermarks/{id:int}"); ShouldHaveActionRoute("DELETE", "/api/v1/watermarks/{id:int}"); } [Test] public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract() { ParameterInfo createRequest = typeof(WatermarkController) .GetMethod(nameof(WatermarkController.Create))! .GetParameters() .Single(p => p.Name == "request"); ParameterInfo updateRequest = typeof(WatermarkController) .GetMethod(nameof(WatermarkController.Update))! .GetParameters() .Single(p => p.Name == "request"); createRequest.ParameterType.ShouldBe(typeof(CreateWatermarkRequest)); updateRequest.ParameterType.ShouldBe(typeof(UpdateWatermarkRequest)); } [Test] public async Task GetAll_Should_Return_Watermarks() { List models = [ new WatermarkResponseModel(1, "Corner Logo", ChannelWatermarkImageSource.Custom), new WatermarkResponseModel(2, "Ticker", ChannelWatermarkImageSource.ChannelLogo) ]; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(models); List result = await _controller.GetAll(CancellationToken.None); result.ShouldBe(models); } [Test] public async Task GetById_Should_Return_200_For_Some() { WatermarkFullResponseModel vm = MakeVm(4); _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); } [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreateWatermarkResult(7))); WatermarkFullResponseModel vm = MakeVm(7); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None); var created = result.ShouldBeOfType(); created.StatusCode.ShouldBe(201); created.Location.ShouldBe("/api/v1/watermarks/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(MakeCreateRequest(), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Update_Should_Return_200_And_Map_Route_Id_To_Command() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new UpdateWatermarkResult(8))); WatermarkFullResponseModel vm = MakeVm(8); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Update(8, MakeUpdateRequest(), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(vm); await _mediator.Received(1).Send( Arg.Is(c => c.Id == 8), 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, MakeUpdateRequest(), 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(); } private static void ShouldHaveActionRoute(string httpMethod, string route) { bool exists = typeof(WatermarkController) .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .SelectMany(m => m.GetCustomAttributes(inherit: true)) .Any(a => a.HttpMethods.Contains(httpMethod) && a.Template == route); exists.ShouldBeTrue($"Missing route {httpMethod} {route}"); } private static WatermarkFullResponseModel MakeVm(int id) => new( id, "Corner Logo", ChannelWatermarkMode.Permanent, ChannelWatermarkImageSource.Custom, "abc.png", "image/png", WatermarkLocation.BottomRight, WatermarkSize.Scaled, 15, 5, 5, 15, 10, 80, null, 0, false); private static CreateWatermarkRequest MakeCreateRequest() => new( "Corner Logo", ChannelWatermarkMode.Permanent, ChannelWatermarkImageSource.Custom, "abc.png", "image/png", WatermarkLocation.BottomRight, WatermarkSize.Scaled, 15, 5, 5, 15, 10, 80, null, 0, false); private static UpdateWatermarkRequest MakeUpdateRequest() => new( "Corner Logo", ChannelWatermarkMode.Permanent, ChannelWatermarkImageSource.Custom, "abc.png", "image/png", WatermarkLocation.BottomRight, WatermarkSize.Scaled, 15, 5, 5, 15, 10, 80, null, 0, false); }