using ErsatzTV.Application.MediaCollections; using ErsatzTV.Core; using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.MediaCollections; [TestFixture] public class GetCollectionItemsHandlerTests { private InMemoryTvContext _db = null!; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task Handle_Should_Return_NotFound_For_Missing_Collection() { var handler = new GetCollectionItemsHandler(_db.Factory); Either result = await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None); result.IsLeft.ShouldBeTrue(); } [Test] public async Task Handle_Should_Order_By_Title_When_Custom_Order_Disabled() { await SeedCollectionGraph(useCustomPlaybackOrder: false); var handler = new GetCollectionItemsHandler(_db.Factory); Either result = await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None); PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single(); page.TotalCount.ShouldBe(3); page.Page.Select(i => i.Title).ShouldBe(["Alpha", "Beta", "Zeta"]); } [Test] public async Task Handle_Should_Order_By_CustomIndex_With_Nulls_Last_When_Custom_Order_Enabled() { await SeedCollectionGraph(useCustomPlaybackOrder: true); var handler = new GetCollectionItemsHandler(_db.Factory); Either result = await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None); PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single(); // Zeta has CustomIndex 0, Alpha has CustomIndex 1, Beta has no CustomIndex (sorts last). page.Page.Select(i => i.Title).ShouldBe(["Zeta", "Alpha", "Beta"]); } // Paging semantics, pinned because #616 reported them as two bugs that measurement did not // support. pageNum is 0-BASED (the trap: the MCP catalog documented it as 1-based, so a caller // starting at 1 silently skipped the first page and read a short set as data loss). [Test] public async Task Handle_Should_Treat_PageNum_As_Zero_Based() { await SeedNumberedCollection(150); var handler = new GetCollectionItemsHandler(_db.Factory); Either first = await handler.Handle(new GetCollectionItems(10, 0, 10), CancellationToken.None); Either second = await handler.Handle(new GetCollectionItems(10, 1, 10), CancellationToken.None); // Page 0 is the FIRST page, not a skipped one; page 1 is the second. first.RightToSeq().Single().Page.Select(i => i.Title).First().ShouldBe("Item 001"); second.RightToSeq().Single().Page.Select(i => i.Title).First().ShouldBe("Item 011"); } // The second half of #616's claim was that an over-large pageSize caps the returned page but // leaves the OFFSET computed from the requested value, so page 1 at pageSize=500 would land past // item 500. It does not: the size is clamped first and the offset derives from the clamped value. [Test] public async Task Handle_Should_Derive_Offset_From_The_Clamped_PageSize() { await SeedNumberedCollection(150); var handler = new GetCollectionItemsHandler(_db.Factory); // pageSize 500 clamps to 100, so page 1 starts at item 101 and runs to the end (50 items). // If the offset honored the requested 500, this page would start past the collection and be // empty — which is exactly what the mutation of this fix produces. Either result = await handler.Handle(new GetCollectionItems(10, 1, 500), CancellationToken.None); PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single(); page.TotalCount.ShouldBe(150); page.Page.Count.ShouldBe(50); page.Page.Select(i => i.Title).First().ShouldBe("Item 101"); page.Page.Select(i => i.Title).Last().ShouldBe("Item 150"); } private async Task SeedNumberedCollection(int count) { await using TvContext context = _db.CreateContext(); var library = new LocalLibrary { Id = 1, Name = "Library", MediaKind = LibraryMediaKind.Movies, Paths = [] }; var path = new LibraryPath { Id = 1, Path = "/media", Library = library, LibraryFolders = [], MediaItems = [] }; library.Paths.Add(path); var collection = new Collection { Id = 10, Name = "Manual", UseCustomPlaybackOrder = false, MediaItems = [], CollectionItems = [], MultiCollections = [], MultiCollectionItems = [] }; var movies = new List(); for (var i = 1; i <= count; i++) { // Zero-padded so the handler's title ordering matches numeric order. movies.Add(MakeMovie(1000 + i, path, $"Item {i:D3}")); collection.CollectionItems.Add(new CollectionItem { MediaItemId = 1000 + i }); } context.LocalLibraries.Add(library); context.Movies.AddRange(movies); context.Collections.Add(collection); await context.SaveChangesAsync(); } private async Task SeedCollectionGraph(bool useCustomPlaybackOrder) { await using TvContext context = _db.CreateContext(); var library = new LocalLibrary { Id = 1, Name = "Library", MediaKind = LibraryMediaKind.Movies, Paths = [] }; var path = new LibraryPath { Id = 1, Path = "/media", Library = library, LibraryFolders = [], MediaItems = [] }; library.Paths.Add(path); Movie alpha = MakeMovie(101, path, "Alpha"); Movie beta = MakeMovie(102, path, "Beta"); Movie zeta = MakeMovie(103, path, "Zeta"); var collection = new Collection { Id = 10, Name = "Manual", UseCustomPlaybackOrder = useCustomPlaybackOrder, MediaItems = [], CollectionItems = [ new CollectionItem { MediaItemId = 103, CustomIndex = 0 }, new CollectionItem { MediaItemId = 101, CustomIndex = 1 }, new CollectionItem { MediaItemId = 102, CustomIndex = null } ], MultiCollections = [], MultiCollectionItems = [] }; context.LocalLibraries.Add(library); context.Movies.AddRange(alpha, beta, zeta); context.Collections.Add(collection); await context.SaveChangesAsync(); } private static Movie MakeMovie(int id, LibraryPath path, string title) => new() { Id = id, LibraryPath = path, Collections = [], CollectionItems = [], TraktListItems = [], MovieMetadata = [ new MovieMetadata { Title = title, SortTitle = title, Artwork = [], Genres = [], Tags = [], Studios = [], Actors = [], Guids = [], Subtitles = [], Directors = [], Writers = [] } ], MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }] }; }