Cold independent review of 017ef988d. Three findings taken, one filed out.
The important one is a regression this branch INTRODUCED. `SongMetadata.Artists`
is a nullable EF primitive collection (JSON in one column, not a navigation)
that FallbackMetadataProvider leaves unassigned when a song's tags fail to read,
and `string.Join` throws ArgumentNullException on a null sequence. The rerun
list previously did not load SongMetadata at all, so the throw was unreachable
there; adding the include promoted it to a live 500 that would have failed the
whole page. Confirmed by reverting the guard: ArgumentNullException, parameter
'values'. The file header claiming every member was guarded was false.
The empty case is filtered too, so an artist-less song loses its bare " - ".
Second: `GetPlaylistItemsHandler` had no handler-level test at all (its
controller tests stub the mediator), so the RemoteStream include added last
round was discharged by inspection -- the same method that produced #671. It
now runs the same 13-type matrix via a shared SelectionSeedData; removing the
include fails that matrix.
Third: dropped the dead `(i as Season).SeasonMetadata` include leg -- the Season
projection reads Show.ShowMetadata and the scalar SeasonNumber, never
SeasonMetadata.
Filed #690 for the pre-existing, out-of-scope finding: the paged TotalCount
ignores the search query, so the SPA renders empty pages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
167 lines
6.8 KiB
C#
167 lines
6.8 KiB
C#
using ErsatzTV.Application.MediaCollections;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Application.MediaCollections;
|
|
|
|
/// <summary>
|
|
/// Read-path coverage for the two rerun-collection query handlers (issue #671). The defect was
|
|
/// precisely that nobody enumerated the selection types: the list handler eager-loaded nothing, and
|
|
/// the by-id handler loaded metadata for only four of the ten media types. So the matrix is derived
|
|
/// from the production predicate (see <see cref="SelectionSeedData" />) rather than hand-listed.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class RerunCollectionQueryHandlerTests : MediaCollectionHandlerTestBase
|
|
{
|
|
private static IEnumerable<CollectionType> SupportedSelectionTypes => SelectionSeedData.SupportedSelectionTypes;
|
|
|
|
/// <summary>
|
|
/// Completeness guard. Without it, a change that narrowed <c>IsSupportedSelectionType</c> would
|
|
/// shrink the matrix silently and every remaining case would still pass — the "filters on the
|
|
/// property it asserts" failure mode. Set equality, so it fails on widening too.
|
|
/// </summary>
|
|
[Test]
|
|
public void Supported_Selection_Types_Should_Be_The_Full_Documented_Set()
|
|
{
|
|
SupportedSelectionTypes.ShouldBe(
|
|
[
|
|
CollectionType.Collection,
|
|
CollectionType.TelevisionShow,
|
|
CollectionType.TelevisionSeason,
|
|
CollectionType.Artist,
|
|
CollectionType.MultiCollection,
|
|
CollectionType.SmartCollection,
|
|
CollectionType.Movie,
|
|
CollectionType.Episode,
|
|
CollectionType.MusicVideo,
|
|
CollectionType.OtherVideo,
|
|
CollectionType.Song,
|
|
CollectionType.Image,
|
|
CollectionType.RemoteStream
|
|
],
|
|
ignoreOrder: true);
|
|
}
|
|
|
|
[TestCaseSource(nameof(SupportedSelectionTypes))]
|
|
public async Task GetById_Should_Resolve_The_Selection(CollectionType collectionType)
|
|
{
|
|
await SeedSelection(collectionType);
|
|
await SeedRerunCollection(1, collectionType);
|
|
|
|
var handler = new GetRerunCollectionByIdHandler(Db.Factory);
|
|
|
|
Option<RerunCollectionViewModel> result =
|
|
await handler.Handle(new GetRerunCollectionById(1), CancellationToken.None);
|
|
|
|
RerunCollectionViewModel vm = result.IfNone(() => throw new AssertionException("Expected a result"));
|
|
AssertSelectionResolved(vm, collectionType);
|
|
}
|
|
|
|
[TestCaseSource(nameof(SupportedSelectionTypes))]
|
|
public async Task GetPaged_Should_Resolve_The_Selection(CollectionType collectionType)
|
|
{
|
|
await SeedSelection(collectionType);
|
|
await SeedRerunCollection(1, collectionType);
|
|
|
|
var handler = new GetPagedRerunCollectionsHandler(Db.Factory);
|
|
|
|
PagedRerunCollectionsViewModel result = await handler.Handle(
|
|
new GetPagedRerunCollections(string.Empty, 0, 10),
|
|
CancellationToken.None);
|
|
|
|
result.Page.Count.ShouldBe(1);
|
|
AssertSelectionResolved(result.Page[0], collectionType);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>SongMetadata.Artists</c> is a NULLABLE primitive collection, and a song whose tags failed to
|
|
/// read is persisted with it never assigned. Before #671 the rerun list did not load SongMetadata
|
|
/// at all, so this was unreachable there; eager-loading it made a latent `string.Join` throw into a
|
|
/// live 500 that would take down the whole page.
|
|
/// </summary>
|
|
[TestCase(null, "Selected song", TestName = "GetById_Song_With_Null_Artists_Should_Not_Throw")]
|
|
[TestCase(new string[] { }, "Selected song", TestName = "GetById_Song_With_No_Artists_Should_Not_Prefix")]
|
|
public async Task GetById_Should_Tolerate_Song_Artists(string[] artists, string expectedName)
|
|
{
|
|
await using (TvContext context = Db.CreateContext())
|
|
{
|
|
context.Songs.Add(new Song
|
|
{
|
|
Id = SelectionSeedData.SelectedId,
|
|
SongMetadata = [new SongMetadata { Title = "Selected song", Artists = artists?.ToList() }]
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
await SeedRerunCollection(1, CollectionType.Song);
|
|
|
|
var handler = new GetRerunCollectionByIdHandler(Db.Factory);
|
|
|
|
Option<RerunCollectionViewModel> result =
|
|
await handler.Handle(new GetRerunCollectionById(1), CancellationToken.None);
|
|
|
|
RerunCollectionViewModel vm = result.IfNone(() => throw new AssertionException("Expected a result"));
|
|
vm.MediaItem.ShouldNotBeNull();
|
|
vm.MediaItem.MediaItemId.ShouldBe(SelectionSeedData.SelectedId);
|
|
vm.MediaItem.Name.ShouldBe(expectedName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors <c>RerunCollectionController.ProjectToResponseModel</c>, which flattens the tagged
|
|
/// union to the single <c>selectedId</c> / <c>selectedName</c> pair the SPA consumes. The id is
|
|
/// the load-bearing half: the editor round-trips it, so a null there silently clears the user's
|
|
/// stored selection.
|
|
/// </summary>
|
|
private static void AssertSelectionResolved(RerunCollectionViewModel vm, CollectionType collectionType)
|
|
{
|
|
int? selectedId = vm.Collection?.Id
|
|
?? vm.MultiCollection?.Id
|
|
?? vm.SmartCollection?.Id
|
|
?? vm.MediaItem?.MediaItemId;
|
|
|
|
string selectedName = vm.Collection?.Name
|
|
?? vm.MultiCollection?.Name
|
|
?? vm.SmartCollection?.Name
|
|
?? vm.MediaItem?.Name;
|
|
|
|
selectedId.ShouldBe(SelectionSeedData.SelectedId, $"{collectionType} lost its selected id");
|
|
selectedName.ShouldBe(
|
|
SelectionSeedData.ExpectedName(collectionType),
|
|
$"{collectionType} projected the wrong name");
|
|
}
|
|
|
|
private async Task SeedSelection(CollectionType collectionType)
|
|
{
|
|
await using TvContext context = Db.CreateContext();
|
|
await SelectionSeedData.SeedSelection(context, collectionType);
|
|
}
|
|
|
|
private async Task SeedRerunCollection(int id, CollectionType collectionType)
|
|
{
|
|
await using TvContext context = Db.CreateContext();
|
|
|
|
var rerunCollection = new RerunCollection
|
|
{
|
|
Id = id,
|
|
Name = "Rerun",
|
|
CollectionType = collectionType,
|
|
FirstRunPlaybackOrder = PlaybackOrder.Chronological,
|
|
RerunPlaybackOrder = PlaybackOrder.Chronological
|
|
};
|
|
|
|
SelectionSeedData.ApplySelection(
|
|
collectionType,
|
|
v => rerunCollection.CollectionId = v,
|
|
v => rerunCollection.MultiCollectionId = v,
|
|
v => rerunCollection.SmartCollectionId = v,
|
|
v => rerunCollection.MediaItemId = v);
|
|
|
|
context.RerunCollections.Add(rerunCollection);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
}
|