Files
ersatztv/ErsatzTV.Tests/Application/MediaCollections/CollectionHandlerTests.cs
T
timothyandClaude Opus 4.8 f869dfe87a feat(api): GET /api/collections/{id}/items (paged) + confirm POST-items 422 guard (#155)
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>
2026-07-08 18:26:52 +02:00

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"));
}