Files
ersatztv/ErsatzTV.Tests/Controllers/MediaDetailControllerTests.cs
T
timothyandClaude Fable 5 cd31c755bb feat(api): media detail + info + image-folder endpoints (#141/#161)
Add REST endpoints backing the SPA media detail pages and image browser:

- GET /api/movies/{id}, /api/shows/{id}, /api/seasons/{id}, /api/artists/{id}
  wrapping the existing detail queries; 404 on None.
- GET /api/media-items/{id}/info wrapping GetMediaItemInfo; UnableToLocateMediaItem
  -> 404, other errors -> 422.
- GET /api/images/folders?parentId= and PUT /api/images/folders/{id}/duration
  (validates null-or-positive -> 400; existence guard via new ImageFolderExists
  query -> 404).
- Extend GetLibraryBrowseItems parentId drill-in to Episode (episodes of a season,
  episode-number order) and MusicVideo (an artist's music videos, album/track order),
  alongside the existing TelevisionSeason branch.

Response DTOs live in ErsatzTV.Core/Api/* and never expose Application VMs. Artwork
values are rooted for the SPA via a shared ErsatzTV.Core/Api/ApiArtwork helper
(mirrors the #180/#181 browse-handler logic; handles jellyfin/emby proxy prefixes,
http passthrough, empty). Regenerated OpenAPI v1.json + web v1.d.ts. New controllers
registered in ApiControllerSecurityTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:43:47 +02:00

263 lines
9.8 KiB
C#

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<IMediator>();
[Test]
public void Controllers_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute<MoviesController>(nameof(MoviesController.GetById), "GET", "/api/movies/{id:int}");
ShouldHaveActionRoute<ShowsController>(nameof(ShowsController.GetById), "GET", "/api/shows/{id:int}");
ShouldHaveActionRoute<SeasonsController>(nameof(SeasonsController.GetById), "GET", "/api/seasons/{id:int}");
ShouldHaveActionRoute<ArtistsController>(nameof(ArtistsController.GetById), "GET", "/api/artists/{id:int}");
ShouldHaveActionRoute<MediaItemsController>(
nameof(MediaItemsController.GetInfo),
"GET",
"/api/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<GetMovieById>(), Arg.Any<CancellationToken>())
.Returns(Option<MovieViewModel>.Some(vm));
var controller = new MoviesController(_mediator);
IActionResult result = await controller.GetById(5, CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
var body = ok.Value.ShouldBeOfType<MovieDetailResponseModel>();
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<GetMovieById>(), Arg.Any<CancellationToken>())
.Returns(Option<MovieViewModel>.None);
var controller = new MoviesController(_mediator);
IActionResult result = await controller.GetById(5, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>().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<GetTelevisionShowById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionShowViewModel>.Some(vm));
var controller = new ShowsController(_mediator);
IActionResult result = await controller.GetById(3, CancellationToken.None);
var body = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ShowDetailResponseModel>();
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<GetTelevisionShowById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionShowViewModel>.None);
var controller = new ShowsController(_mediator);
(await controller.GetById(3, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[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<GetTelevisionSeasonById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionSeasonViewModel>.Some(vm));
var controller = new SeasonsController(_mediator);
var body = (await controller.GetById(4, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<SeasonDetailResponseModel>();
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<GetTelevisionSeasonById>(), Arg.Any<CancellationToken>())
.Returns(Option<TelevisionSeasonViewModel>.None);
var controller = new SeasonsController(_mediator);
(await controller.GetById(4, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[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<GetArtistById>(), Arg.Any<CancellationToken>())
.Returns(Option<ArtistViewModel>.Some(vm));
var controller = new ArtistsController(_mediator);
var body = (await controller.GetById(6, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ArtistDetailResponseModel>();
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<GetArtistById>(), Arg.Any<CancellationToken>())
.Returns(Option<ArtistViewModel>.None);
var controller = new ArtistsController(_mediator);
(await controller.GetById(6, CancellationToken.None)).ShouldBeOfType<NotFoundObjectResult>();
}
[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<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Right(info));
var controller = new MediaItemsController(_mediator);
var body = (await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MediaItemInfoResponseModel>();
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<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Left(new UnableToLocateMediaItem()));
var controller = new MediaItemsController(_mediator);
(await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
}
[Test]
public async Task MediaItemInfo_Should_Return_422_On_Other_Error()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Left(BaseError.New("boom")));
var controller = new MediaItemsController(_mediator);
(await controller.GetInfo(9, CancellationToken.None))
.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
}
private static void ShouldHaveActionRoute<TController>(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(TController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}