using System.Reflection; using ErsatzTV.Application.FFmpegProfiles; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.FFmpegProfiles; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; 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 FFmpegProfileControllerTests { private FFmpegProfileController _controller = null!; private IMediator _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _controller = new FFmpegProfileController(_mediator); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute("GET", "/api/v1/ffmpeg/profiles"); ShouldHaveActionRoute("GET", "/api/v1/ffmpeg/profiles/{id:int}"); ShouldHaveActionRoute("POST", "/api/v1/ffmpeg/profiles"); ShouldHaveActionRoute("PUT", "/api/v1/ffmpeg/profiles/{id:int}"); ShouldHaveActionRoute("DELETE", "/api/v1/ffmpeg/profiles/{id:int}"); } [Test] public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract() { ParameterInfo createRequest = typeof(FFmpegProfileController) .GetMethod(nameof(FFmpegProfileController.AddOne))! .GetParameters() .Single(p => p.Name == "request"); ParameterInfo updateRequest = typeof(FFmpegProfileController) .GetMethod(nameof(FFmpegProfileController.UpdateOne))! .GetParameters() .Single(p => p.Name == "request"); createRequest.ParameterType.ShouldBe(typeof(CreateFFmpegProfileRequest)); updateRequest.ParameterType.ShouldBe(typeof(UpdateFFmpegProfileRequest)); } [Test] public async Task GetById_Should_Return_200_For_Some() { FFmpegFullProfileResponseModel 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); problemDetails.Title.ShouldBe("Resource not found"); } [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreateFFmpegProfileResult(7))); FFmpegFullProfileResponseModel vm = MakeVm(7); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None); var created = result.ShouldBeOfType(); created.StatusCode.ShouldBe(201); created.Location.ShouldBe("/api/v1/ffmpeg/profiles/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.AddOne(MakeCreateRequest(), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Create_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.AddOne(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 UpdateFFmpegProfileResult(8))); FFmpegFullProfileResponseModel vm = MakeVm(8); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.UpdateOne(8, MakeUpdateRequest(), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(vm); await _mediator.Received(1).Send( Arg.Is(c => c.FFmpegProfileId == 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.UpdateOne(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.DeleteProfileAsync(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.DeleteProfileAsync(9, CancellationToken.None); result.ShouldBeOfType(); } private static void ShouldHaveActionRoute(string httpMethod, string route) { bool exists = typeof(FFmpegProfileController) .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 FFmpegFullProfileResponseModel MakeVm(int id) => new( id, "Default", 1, true, true, HardwareAccelerationKind.None, "drm", VaapiDriver.Default, "/dev/dri/renderD128", null, 1, "HD", ScalingBehavior.ScaleAndPad, FilterMode.Software, FFmpegProfileVideoFormat.H264, string.Empty, string.Empty, false, FFmpegProfileBitDepth.EightBit, 2_000, 4_000, FFmpegProfileTonemapAlgorithm.Linear, FFmpegProfileAudioFormat.Aac, 192, 384, NormalizeLoudnessMode.Off, null, 2, 48_000, false, true, false, true, null, null); private static CreateFFmpegProfileRequest MakeCreateRequest() => new( "Default", 1, true, true, HardwareAccelerationKind.None, "drm", VaapiDriver.Default, "/dev/dri/renderD128", null, 1, ScalingBehavior.ScaleAndPad, FilterMode.Software, FFmpegProfileVideoFormat.H264, string.Empty, string.Empty, false, FFmpegProfileBitDepth.EightBit, 2_000, 4_000, FFmpegProfileTonemapAlgorithm.Linear, FFmpegProfileAudioFormat.Aac, 192, 384, NormalizeLoudnessMode.Off, null, 2, 48_000, false, false, false); private static UpdateFFmpegProfileRequest MakeUpdateRequest() => new( "Default", 1, true, true, HardwareAccelerationKind.None, "drm", VaapiDriver.Default, "/dev/dri/renderD128", null, 1, ScalingBehavior.ScaleAndPad, FilterMode.Software, FFmpegProfileVideoFormat.H264, string.Empty, string.Empty, false, FFmpegProfileBitDepth.EightBit, 2_000, 4_000, FFmpegProfileTonemapAlgorithm.Linear, FFmpegProfileAudioFormat.Aac, 192, 384, NormalizeLoudnessMode.Off, null, 2, 48_000, false, false, false); }