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; /// /// 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 ) rather than hand-listed. /// [TestFixture] public class RerunCollectionQueryHandlerTests : MediaCollectionHandlerTestBase { private static IEnumerable SupportedSelectionTypes => SelectionSeedData.SupportedSelectionTypes; /// /// Completeness guard. Without it, a change that narrowed IsSupportedSelectionType 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. /// [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 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); } /// /// SongMetadata.Artists 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. /// [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 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); } /// /// Mirrors RerunCollectionController.ProjectToResponseModel, which flattens the tagged /// union to the single selectedId / selectedName 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. /// 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(); } }