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
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>
335 lines
12 KiB
C#
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 = []
|
|
}
|
|
]
|
|
};
|
|
}
|