Files
ersatztv/ErsatzTV.Tests/Application/Channels/GetAutoTuneChannelMembersHandlerTests.cs
T
timothyandClaude Opus 4.8 1be45fbe88
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 6m6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 7m8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 10m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(384): guard out-of-range axis (L1 review) + tidy page clamp
Cold adversarial review found a crafted numeric `?axis=5` binds past
[ApiController]'s auto-400 (ModelState valid), then AutoTuneAxisMap.GenerateQuery's
`_ => throw` surfaces as a 500 (no global exception filter). Short-circuit an
undefined axis to an empty result in the handler — matching #69's EnumerateAxis
`_ => []` graceful-empty pattern. Adds a regression test asserting no search runs.

Also simplifies the redundant pageSize lower clamp (review N4): the `<= 0 ? 100`
guard already floors it, so `Math.Clamp(_, 1, 200)` -> `Math.Min(_, 200)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:40:44 +02:00

335 lines
12 KiB
C#

using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetAutoTuneChannelMembersHandlerTests
{
private InMemoryTvContext _db = null!;
private ISearchIndex _searchIndex = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_searchIndex = Substitute.For<ISearchIndex>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private void ReturnsSearch(params SearchItem[] items) =>
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult(items.ToList(), items.Length));
[Test]
public async Task Handle_Should_Generate_The_Server_Owned_Query_For_The_Axis_Value()
{
ReturnsSearch();
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 100),
CancellationToken.None);
// The client never sends Lucene; the handler builds it from AutoTuneAxisMap.GenerateQuery.
await _searchIndex.Received(1).Search(
Arg.Is<string>(q => q == "type:episode AND genre:\"Comedy\""),
string.Empty,
0,
10_000,
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Not_Search_For_A_Blank_Value()
{
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, " ", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(0);
result.Page.ShouldBeEmpty();
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Roll_Episodes_Up_To_Distinct_Parent_Shows_With_Matching_Counts()
{
await SeedTwoShowGenreGraph();
// Show 10 contributes episodes 101 & 102; show 20 contributes episode 201. Episode 103 (show 10)
// exists but does NOT match the query, so it must not inflate show 10's count.
ReturnsSearch(
new SearchItem(LuceneSearchIndex.EpisodeType, 101),
new SearchItem(LuceneSearchIndex.EpisodeType, 102),
new SearchItem(LuceneSearchIndex.EpisodeType, 201));
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
// Ordered by title: "Alpha Show" (10) before "Beta Show" (20).
result.Page.Count.ShouldBe(2);
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.TelevisionShow);
result.Page[0].Title.ShouldBe("Alpha Show");
result.Page[0].Id.ShouldBe(10);
result.Page[0].ItemCount.ShouldBe(2); // matching episodes only, not the show's 3 total
result.Page[1].Title.ShouldBe("Beta Show");
result.Page[1].Id.ShouldBe(20);
result.Page[1].ItemCount.ShouldBe(1);
}
[Test]
public async Task Handle_Should_Page_Distinct_Shows_By_Title()
{
await SeedTwoShowGenreGraph();
ReturnsSearch(
new SearchItem(LuceneSearchIndex.EpisodeType, 101),
new SearchItem(LuceneSearchIndex.EpisodeType, 201));
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel page0 = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 1),
CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page1 = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 1, 1),
CancellationToken.None);
page0.TotalCount.ShouldBe(2);
page0.Page.Count.ShouldBe(1);
page0.Page[0].Title.ShouldBe("Alpha Show");
page1.TotalCount.ShouldBe(2);
page1.Page.Count.ShouldBe(1);
page1.Page[0].Title.ShouldBe("Beta Show");
}
[Test]
public async Task Handle_Should_Return_Movies_As_Members_For_The_Movie_Genre_Axis()
{
await SeedMovieGenreGraph();
ReturnsSearch(
new SearchItem(LuceneSearchIndex.MovieType, 30),
new SearchItem(LuceneSearchIndex.MovieType, 31));
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Comedy", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
// Ordered by title: "Aardvark" before "Zebra".
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
result.Page[0].Title.ShouldBe("Aardvark");
result.Page[0].Id.ShouldBe(31);
result.Page[0].ItemCount.ShouldBe(1);
result.Page[1].Title.ShouldBe("Zebra");
result.Page[1].Id.ShouldBe(30);
}
[Test]
public async Task Handle_Should_Return_Empty_When_The_Query_Matches_Nothing()
{
await SeedTwoShowGenreGraph();
ReturnsSearch();
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Nonexistent", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(0);
result.Page.ShouldBeEmpty();
}
[Test]
public async Task Handle_Should_Return_Empty_For_An_Out_Of_Range_Axis_Without_Searching()
{
// A crafted numeric axis (?axis=5) binds successfully; the handler must not let
// GenerateQuery throw (which would surface as a 500) — it returns empty like a blank value.
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers((AutoTuneAxis)999, "Comedy", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(0);
result.Page.ShouldBeEmpty();
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>());
}
private async Task SeedTwoShowGenreGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(1, "TV");
var alpha = MakeShow(10, path, "Alpha Show");
Season alphaSeason = MakeSeason(11, path, alpha);
alphaSeason.Episodes.AddRange([
MakeEpisode(101, path, alphaSeason),
MakeEpisode(102, path, alphaSeason),
MakeEpisode(103, path, alphaSeason)
]);
alpha.Seasons.Add(alphaSeason);
var beta = MakeShow(20, path, "Beta Show");
Season betaSeason = MakeSeason(21, path, beta);
betaSeason.Episodes.Add(MakeEpisode(201, path, betaSeason));
beta.Seasons.Add(betaSeason);
path.MediaItems.AddRange([
alpha, beta, alphaSeason, betaSeason,
.. alphaSeason.Episodes, .. betaSeason.Episodes
]);
context.LocalLibraries.Add(library);
context.Shows.AddRange(alpha, beta);
context.Seasons.AddRange(alphaSeason, betaSeason);
context.Episodes.AddRange([.. alphaSeason.Episodes, .. betaSeason.Episodes]);
await context.SaveChangesAsync();
}
private async Task SeedMovieGenreGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(2, "Movies");
var zebra = MakeMovie(30, path, "Zebra");
var aardvark = MakeMovie(31, path, "Aardvark");
path.MediaItems.AddRange([zebra, aardvark]);
context.LocalLibraries.Add(library);
context.Movies.AddRange(zebra, aardvark);
await context.SaveChangesAsync();
}
private static (LocalLibrary Library, LibraryPath Path) MakeLibrary(int id, string name)
{
var library = new LocalLibrary
{
Id = id,
Name = name,
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = id,
Path = $"/media/{id}",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
return (library, path);
}
private static Show MakeShow(int id, LibraryPath path, string title) =>
new()
{
Id = id,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata =
[
new ShowMetadata
{
Title = title,
SortTitle = title,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
}
]
};
private static Season MakeSeason(int id, LibraryPath path, Show show) =>
new()
{
Id = id,
LibraryPath = path,
Show = show,
SeasonNumber = 1,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Episodes = [],
SeasonMetadata = []
};
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
new()
{
Id = id,
LibraryPath = path,
Season = season,
Collections = [],
CollectionItems = [],
TraktListItems = [],
EpisodeMetadata = [],
MediaVersions = []
};
private static Movie MakeMovie(int id, LibraryPath path, string title) =>
new()
{
Id = id,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(90) }],
MovieMetadata =
[
new MovieMetadata
{
Title = title,
SortTitle = title,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
]
};
}