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>
263 lines
9.8 KiB
C#
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);
|
|
}
|
|
}
|