using System.Globalization; using System.Reflection; using ErsatzTV.Application.Artists; using ErsatzTV.Application.MediaCards; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Movies; using ErsatzTV.Application.Television; using ErsatzTV.Controllers.Api; using ErsatzTV.Core; using ErsatzTV.Core.Api.Artists; using ErsatzTV.Core.Api.MediaItems; using ErsatzTV.Core.Api.Movies; using ErsatzTV.Core.Api.Television; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; 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 MediaDetailControllerTests { private IMediator _mediator = null!; [SetUp] public void SetUp() => _mediator = Substitute.For(); [Test] public void Controllers_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(MoviesController.GetById), "GET", "/api/v1/movies/{id:int}"); ShouldHaveActionRoute(nameof(ShowsController.GetById), "GET", "/api/v1/shows/{id:int}"); ShouldHaveActionRoute(nameof(SeasonsController.GetById), "GET", "/api/v1/seasons/{id:int}"); ShouldHaveActionRoute(nameof(ArtistsController.GetById), "GET", "/api/v1/artists/{id:int}"); ShouldHaveActionRoute( nameof(MediaItemsController.GetInfo), "GET", "/api/v1/media-items/{id:int}/info"); } [Test] public async Task Movie_Should_Return_200_With_Rooted_Artwork() { var vm = new MovieViewModel( "The Movie", "1999", "A plot", ["Drama"], ["tag"], ["Studio"], ["PG"], ["English"], [new ActorCardViewModel(7, "Actor", "Role", "actor.jpg", MediaItemState.Normal)], ["Director"], ["Writer"], "/media/movie.mkv", "/local/movie.mkv", MediaItemState.Normal) { Poster = "poster.jpg", FanArt = "https://example.com/fan.jpg" }; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); var controller = new MoviesController(_mediator); IActionResult result = await controller.GetById(5, CancellationToken.None); var ok = result.ShouldBeOfType(); var body = ok.Value.ShouldBeOfType(); body.Id.ShouldBe(5); body.Title.ShouldBe("The Movie"); body.Poster.ShouldBe("/artwork/posters/poster.jpg"); body.FanArt.ShouldBe("https://example.com/fan.jpg"); body.Actors.Single().Thumb.ShouldBe("/artwork/thumbnails/actor.jpg"); body.State.ShouldBe(MediaItemState.Normal); } [Test] public async Task Movie_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); var controller = new MoviesController(_mediator); IActionResult result = await controller.GetById(5, CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(404); } [Test] public async Task Show_Should_Map_Languages_To_English_Names() { var vm = new TelevisionShowViewModel( 3, 2, MediaSourceKind.Local, "The Show", "2010", "Plot", "poster.jpg", "fan.jpg", ["Comedy"], ["tag"], ["Studio"], ["Network"], ["TV-14"], [new CultureInfo("en")], []); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); var controller = new ShowsController(_mediator); IActionResult result = await controller.GetById(3, CancellationToken.None); var body = result.ShouldBeOfType().Value.ShouldBeOfType(); body.Id.ShouldBe(3); body.Poster.ShouldBe("/artwork/posters/poster.jpg"); body.FanArt.ShouldBe("/artwork/fanart/fan.jpg"); body.Languages.ShouldContain(new CultureInfo("en").EnglishName); } [Test] public async Task Show_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); var controller = new ShowsController(_mediator); (await controller.GetById(3, CancellationToken.None)).ShouldBeOfType(); } [Test] public async Task Season_Should_Return_200() { var vm = new TelevisionSeasonViewModel(4, 3, "Show", "2010", "Season 1", "s.jpg", "f.jpg"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); var controller = new SeasonsController(_mediator); var body = (await controller.GetById(4, CancellationToken.None)) .ShouldBeOfType().Value.ShouldBeOfType(); body.ShowId.ShouldBe(3); body.Poster.ShouldBe("/artwork/posters/s.jpg"); } [Test] public async Task Season_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); var controller = new SeasonsController(_mediator); (await controller.GetById(4, CancellationToken.None)).ShouldBeOfType(); } [Test] public async Task Artist_Should_Root_Thumbnail_And_FanArt() { var vm = new ArtistViewModel( "Artist", "Disambig", "Bio", "thumb.jpg", "fan.jpg", ["Rock"], ["Style"], ["Mood"], [new CultureInfo("en")]); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); var controller = new ArtistsController(_mediator); var body = (await controller.GetById(6, CancellationToken.None)) .ShouldBeOfType().Value.ShouldBeOfType(); body.Id.ShouldBe(6); body.Thumbnail.ShouldBe("/artwork/thumbnails/thumb.jpg"); body.FanArt.ShouldBe("/artwork/fanart/fan.jpg"); body.Languages.ShouldContain(new CultureInfo("en").EnglishName); } [Test] public async Task Artist_Should_Return_404_When_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); var controller = new ArtistsController(_mediator); (await controller.GetById(6, CancellationToken.None)).ShouldBeOfType(); } [Test] public async Task MediaItemInfo_Should_Return_200_With_Mapped_Streams() { var info = new MediaItemInfo( 9, "Title", "Movie", "LocalLibrary", null, "Movies", MediaItemState.Normal, TimeSpan.FromMinutes(90), "1:1", "16:9", "24/1", VideoScanKind.Progressive, null, 1920, 1080, [new MediaItemInfoStream(0, MediaStreamKind.Video, "v", "h264", "high", "eng", null, true, null, null, "yuv420p", null, null, null, null, 8, null)], [new MediaItemInfoChapter("Chapter 1", TimeSpan.Zero, TimeSpan.FromMinutes(10))]); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Right(info)); var controller = new MediaItemsController(_mediator); var body = (await controller.GetInfo(9, CancellationToken.None)) .ShouldBeOfType().Value.ShouldBeOfType(); body.Id.ShouldBe(9); body.Streams.Single().Codec.ShouldBe("h264"); body.Chapters.Single().Title.ShouldBe("Chapter 1"); } [Test] public async Task MediaItemInfo_Should_Return_404_When_Not_Located() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Left(new UnableToLocateMediaItem())); var controller = new MediaItemsController(_mediator); (await controller.GetInfo(9, CancellationToken.None)) .ShouldBeOfType().StatusCode.ShouldBe(404); } [Test] public async Task MediaItemInfo_Should_Return_422_On_Other_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Left(BaseError.New("boom"))); var controller = new MediaItemsController(_mediator); (await controller.GetInfo(9, CancellationToken.None)) .ShouldBeOfType().StatusCode.ShouldBe(422); } private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(TController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }