#616 filed three MCP/API paging traps. Two were real; one was not, and one was already half-fixed on main. Verified each against the code before changing it. REAL — pageNum documented as 1-based. `ToolCatalog.Page()` described pageNum as "1-based page number" while every paged controller defaults it to 0, floors it with `Math.Max(0, pageNum)`, and skips `PageNum * PageSize`. A caller that trusted the description started at page 1 and silently lost the first page: no error, just a short set that reads as data loss rather than an off-by-one (it cost #487 a verification pass). Fixed in the description rather than by making the MCP layer 1-based: /api/v1 is additive-only post-freeze, 0-based is load-bearing in a dozen controllers and the SPA, and a 1-based wrapper over a 0-based API would make the same parameter name mean two different things on two surfaces a reader reads together. NOT REAL — "pageSize caps the page but the offset honors the requested value". Not reproducible on any endpoint. Every controller clamps before passing, every handler skips by the clamped size, and GetCollectionItemsHandler re-clamps defensively. The reported observation (pageSize=500&pageNum=2 on a 204-item collection returning 4 items) is exactly correct 0-based behaviour at the clamped width of 100 — page 2 is items 201-204. The issue's own trap-1 table states this. Pinned by test rather than "fixed". ALREADY FIXED — playout LIST rows gained channelId in #297 (2026-07-22), three days before #616 was filed; the report was measured against prod, which runs an older :prod image. The DETAIL response (PlayoutResponseModel) genuinely still lacked it, so channelId is added there (additive) and the reset_channel_playout argument now names the trap: the id spaces overlap numerically, so passing a playout id silently resets a different channel and returns a plausible 202. Tests, both mutation-verified (each fails when its fix is reverted): - ToolCatalogTests pins "0-based" on EVERY paged tool's pageNum description, with a non-empty guard so it can't pass vacuously over an empty tool set. - GetCollectionItemsHandlerTests pins 0-based page boundaries and proves the offset derives from the clamped pageSize (page 1 at pageSize=500 returns items 101-150; the mutation that honors 500 returns an empty page). Docs: new decision record api.paging-zero-based (catalog regenerated), the api-conventions paging bullet, and a Paging section in docs/mcp.md. OpenAPI v1.json + web/src/api/generated/v1.d.ts regenerated for the added field. fixes #616 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
226 lines
8.1 KiB
C#
226 lines
8.1 KiB
C#
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<BaseError, PagedLibraryBrowseItemsResponseModel> 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<BaseError, PagedLibraryBrowseItemsResponseModel> 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<BaseError, PagedLibraryBrowseItemsResponseModel> 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<BaseError, PagedLibraryBrowseItemsResponseModel> first =
|
|
await handler.Handle(new GetCollectionItems(10, 0, 10), CancellationToken.None);
|
|
Either<BaseError, PagedLibraryBrowseItemsResponseModel> 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<BaseError, PagedLibraryBrowseItemsResponseModel> 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<Movie>();
|
|
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) }]
|
|
};
|
|
}
|