Adds a paged collection-items endpoint reusing LibraryBrowseItemResponseModel
so the SPA lists a manual collection's full contents (all media kinds), replacing
the lossy Lucene name-based preview. Confirms POST /items already returns 422 for
bogus ids (guarded by ValidateMediaItems, fb3f2856); adds endpoint-level coverage.
fixes #155
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
8.6 KiB
C#
240 lines
8.6 KiB
C#
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.MediaCollections;
|
|
using ErsatzTV.Application.Search;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Api.LibraryBrowse;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using LanguageExt;
|
|
using ErsatzTV.Tests.Support;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using Unit = LanguageExt.Unit;
|
|
|
|
namespace ErsatzTV.Tests.Application.MediaCollections;
|
|
|
|
[TestFixture]
|
|
public class CollectionHandlerTests : MediaCollectionHandlerTestBase
|
|
{
|
|
[Test]
|
|
public async Task Update_Should_Return_NotFoundError_When_Collection_Missing()
|
|
{
|
|
var handler = new UpdateCollectionHandler(
|
|
Db.Factory,
|
|
Substitute.For<IMediaCollectionRepository>(),
|
|
Worker,
|
|
SearchTargets);
|
|
|
|
Either<BaseError, Unit> result =
|
|
await handler.Handle(new UpdateCollection(999, "Updated"), CancellationToken.None);
|
|
|
|
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Delete_Should_Return_NotFoundError_When_Collection_Missing()
|
|
{
|
|
var handler = new DeleteCollectionHandler(Db.Factory, SearchTargets);
|
|
|
|
Either<BaseError, Unit> result =
|
|
await handler.Handle(new DeleteCollection(999), CancellationToken.None);
|
|
|
|
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task AddItems_Should_Return_NotFoundError_When_Collection_Missing()
|
|
{
|
|
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
|
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
|
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
|
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
|
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
|
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
|
|
|
var handler = new AddItemsToCollectionHandler(
|
|
Db.Factory,
|
|
Substitute.For<IMediaCollectionRepository>(),
|
|
movieRepository,
|
|
televisionRepository,
|
|
Worker,
|
|
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
|
|
|
Either<BaseError, Unit> result =
|
|
await handler.Handle(MakeAddItems(collectionId: 999, movieIds: [1]), CancellationToken.None);
|
|
|
|
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task AddItems_Should_Return_ValidationError_When_Generic_MediaItem_Missing()
|
|
{
|
|
await SeedCollection(1);
|
|
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
|
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
|
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
|
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
|
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
|
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
|
|
|
var handler = new AddItemsToCollectionHandler(
|
|
Db.Factory,
|
|
Substitute.For<IMediaCollectionRepository>(),
|
|
movieRepository,
|
|
televisionRepository,
|
|
Worker,
|
|
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
|
|
|
Either<BaseError, Unit> result =
|
|
await handler.Handle(
|
|
new AddItemsToCollection(1, [], [], [], [], [], [], [], [], [], [999]),
|
|
CancellationToken.None);
|
|
|
|
BaseError error = LeftOf(result);
|
|
error.ShouldNotBeOfType<NotFoundError>();
|
|
error.Value.ShouldContain("Media item does not exist");
|
|
}
|
|
|
|
[Test]
|
|
public async Task RemoveItems_Should_Return_NotFoundError_When_Collection_Missing()
|
|
{
|
|
var handler = new RemoveItemsFromCollectionHandler(
|
|
Db.Factory,
|
|
Substitute.For<IMediaCollectionRepository>(),
|
|
Worker,
|
|
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
|
|
|
Either<BaseError, Unit> result =
|
|
await handler.Handle(
|
|
new RemoveItemsFromCollection(999) { MediaItemIds = [1] },
|
|
CancellationToken.None);
|
|
|
|
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task RemoveItems_Should_Return_NotFoundError_When_Association_Missing()
|
|
{
|
|
await SeedCollection(1);
|
|
var handler = new RemoveItemsFromCollectionHandler(
|
|
Db.Factory,
|
|
Substitute.For<IMediaCollectionRepository>(),
|
|
Worker,
|
|
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
|
|
|
Either<BaseError, Unit> result =
|
|
await handler.Handle(
|
|
new RemoveItemsFromCollection(1) { MediaItemIds = [999] },
|
|
CancellationToken.None);
|
|
|
|
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetItems_Should_Return_NotFoundError_When_Collection_Missing()
|
|
{
|
|
var handler = new GetCollectionItemsHandler(Db.Factory);
|
|
|
|
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
|
|
await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None);
|
|
|
|
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetItems_Should_Return_Members_With_Rooted_Artwork()
|
|
{
|
|
await SeedCollectionWithMovie(collectionId: 1, movieId: 10, title: "Fake Movie", poster: "movie.jpg");
|
|
var handler = new GetCollectionItemsHandler(Db.Factory);
|
|
|
|
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
|
|
await handler.Handle(new GetCollectionItems(1, 0, 100), CancellationToken.None);
|
|
|
|
PagedLibraryBrowseItemsResponseModel page = result.Match(
|
|
Left: _ => throw new AssertionException("Expected a Right result"),
|
|
Right: value => value);
|
|
|
|
page.TotalCount.ShouldBe(1);
|
|
page.Page.Count.ShouldBe(1);
|
|
page.Page[0].Title.ShouldBe("Fake Movie");
|
|
page.Page[0].MediaItemId.ShouldBe(10);
|
|
page.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
|
|
page.Page[0].Artwork.ShouldBe("/artwork/posters/movie.jpg");
|
|
}
|
|
|
|
private async Task SeedCollectionWithMovie(int collectionId, int movieId, string title, string poster)
|
|
{
|
|
await using TvContext context = Db.CreateContext();
|
|
var library = new LocalLibrary
|
|
{
|
|
Id = collectionId,
|
|
Name = "Local",
|
|
MediaKind = LibraryMediaKind.Movies,
|
|
Paths = []
|
|
};
|
|
var path = new LibraryPath
|
|
{
|
|
Id = collectionId,
|
|
Path = "/media",
|
|
Library = library,
|
|
LibraryFolders = [],
|
|
MediaItems = []
|
|
};
|
|
library.Paths.Add(path);
|
|
|
|
var collection = new Collection { Id = collectionId, Name = "Collection", MediaItems = [] };
|
|
var movie = new Movie
|
|
{
|
|
Id = movieId,
|
|
LibraryPath = path,
|
|
Collections = [collection],
|
|
CollectionItems = [],
|
|
TraktListItems = [],
|
|
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(42) }],
|
|
MovieMetadata =
|
|
[
|
|
new MovieMetadata
|
|
{
|
|
Title = title,
|
|
SortTitle = title,
|
|
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Actors = [],
|
|
Guids = [],
|
|
Subtitles = [],
|
|
Directors = [],
|
|
Writers = []
|
|
}
|
|
]
|
|
};
|
|
collection.MediaItems.Add(movie);
|
|
|
|
context.LocalLibraries.Add(library);
|
|
context.Collections.Add(collection);
|
|
context.Movies.Add(movie);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private static AddItemsToCollection MakeAddItems(int collectionId, List<int>? movieIds = null) =>
|
|
new(
|
|
collectionId,
|
|
movieIds ?? [],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[]);
|
|
|
|
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
|
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
|
}
|