using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.Playouts; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using NUnit.Framework; using Shouldly; using DomainChannel = ErsatzTV.Core.Domain.Channel; namespace ErsatzTV.Tests.Application.Paging; /// /// Every paged handler whose page query applies a filter must compute its TotalCount from the SAME /// query, or a filtered page reports the unfiltered total and the SPA paginates to pages that can /// never contain anything (issues #690, #758). /// Expected counts here are PINNED LITERALS derived from the seeded set by hand — never recomputed /// by re-applying the handler's own predicate, which would pass whatever the handler happens to do. /// [TestFixture] public class PagedQueryTotalCountTests { // 5 seeded rows, of which exactly these 2 contain "Alpha" private const int SeededRows = 5; private const int MatchingAlpha = 2; private InMemoryTvContext _db = null!; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); private static readonly string[] Names = ["Alpha One", "Beta", "Alpha Two", "Gamma", "Delta"]; [Test] public async Task GetPagedCollections_Filtered_Count_Should_Match_Filter() { await using (TvContext context = _db.CreateContext()) { for (var i = 0; i < Names.Length; i++) { context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] }); } await context.SaveChangesAsync(); } var handler = new GetPagedCollectionsHandler(_db.Factory); PagedMediaCollectionsViewModel unfiltered = await handler.Handle(new GetPagedCollections(string.Empty, 0, 10), CancellationToken.None); unfiltered.TotalCount.ShouldBe(SeededRows); PagedMediaCollectionsViewModel filtered = await handler.Handle(new GetPagedCollections("Alpha", 0, 10), CancellationToken.None); filtered.TotalCount.ShouldBe(MatchingAlpha); filtered.Page.Select(c => c.Name).ShouldBe(["Alpha One", "Alpha Two"]); } [Test] public async Task GetPagedMultiCollections_Filtered_Count_Should_Match_Filter() { await using (TvContext context = _db.CreateContext()) { for (var i = 0; i < Names.Length; i++) { context.MultiCollections.Add(new MultiCollection { Id = i + 1, Name = Names[i] }); } // channel-owned rows are excluded from BOTH the page and the count, filter or no filter context.MultiCollections.Add( new MultiCollection { Id = 99, Name = "Alpha Owned", OwnedByChannelId = 7 }); await context.SaveChangesAsync(); } var handler = new GetPagedMultiCollectionsHandler(_db.Factory); PagedMultiCollectionsViewModel unfiltered = await handler.Handle(new GetPagedMultiCollections(string.Empty, 0, 10), CancellationToken.None); unfiltered.TotalCount.ShouldBe(SeededRows); PagedMultiCollectionsViewModel filtered = await handler.Handle(new GetPagedMultiCollections("Alpha", 0, 10), CancellationToken.None); filtered.TotalCount.ShouldBe(MatchingAlpha); filtered.Page.Select(mc => mc.Name).ShouldBe(["Alpha One", "Alpha Two"]); } [Test] public async Task GetPagedSmartCollections_Filtered_Count_Should_Match_Filter() { await using (TvContext context = _db.CreateContext()) { for (var i = 0; i < Names.Length; i++) { context.SmartCollections.Add( new SmartCollection { Id = i + 1, Name = Names[i], Query = "tag:family" }); } context.SmartCollections.Add( new SmartCollection { Id = 99, Name = "Alpha Owned", Query = "tag:family", OwnedByChannelId = 7 }); await context.SaveChangesAsync(); } var handler = new GetPagedSmartCollectionsHandler(_db.Factory); PagedSmartCollectionsViewModel unfiltered = await handler.Handle(new GetPagedSmartCollections(string.Empty, 0, 10), CancellationToken.None); unfiltered.TotalCount.ShouldBe(SeededRows); PagedSmartCollectionsViewModel filtered = await handler.Handle(new GetPagedSmartCollections("Alpha", 0, 10), CancellationToken.None); filtered.TotalCount.ShouldBe(MatchingAlpha); filtered.Page.Select(sc => sc.Name).ShouldBe(["Alpha One", "Alpha Two"]); } [Test] public async Task GetPagedRerunCollections_Filtered_Count_Should_Match_Filter() { await using (TvContext context = _db.CreateContext()) { for (var i = 0; i < Names.Length; i++) { context.RerunCollections.Add( new RerunCollection { Id = i + 1, Name = Names[i], CollectionType = CollectionType.Collection, CollectionId = i + 1 }); context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] }); } await context.SaveChangesAsync(); } var handler = new GetPagedRerunCollectionsHandler(_db.Factory); PagedRerunCollectionsViewModel unfiltered = await handler.Handle(new GetPagedRerunCollections(string.Empty, 0, 10), CancellationToken.None); unfiltered.TotalCount.ShouldBe(SeededRows); PagedRerunCollectionsViewModel filtered = await handler.Handle(new GetPagedRerunCollections("Alpha", 0, 10), CancellationToken.None); filtered.TotalCount.ShouldBe(MatchingAlpha); filtered.Page.Select(rc => rc.Name).ShouldBe(["Alpha One", "Alpha Two"]); // the selection graph still loads for the page — moving IncludeSelectionDetails off the // counted query must not stop the page from projecting it (issue #671) filtered.Page.Select(rc => rc.Collection?.Name).ShouldBe(["Alpha One", "Alpha Two"]); } [Test] public async Task GetPagedProgramSchedules_Filtered_Count_Should_Match_Filter() { await using (TvContext context = _db.CreateContext()) { for (var i = 0; i < Names.Length; i++) { context.ProgramSchedules.Add(new ProgramSchedule { Id = i + 1, Name = Names[i] }); } await context.SaveChangesAsync(); } var handler = new GetPagedProgramSchedulesHandler(_db.Factory); PagedProgramSchedulesViewModel unfiltered = await handler.Handle(new GetPagedProgramSchedules(string.Empty, 0, 10), CancellationToken.None); unfiltered.TotalCount.ShouldBe(SeededRows); PagedProgramSchedulesViewModel filtered = await handler.Handle(new GetPagedProgramSchedules("Alpha", 0, 10), CancellationToken.None); filtered.TotalCount.ShouldBe(MatchingAlpha); filtered.Page.Select(ps => ps.Name).ShouldBe(["Alpha One", "Alpha Two"]); } [Test] public async Task GetPagedPlayouts_Filtered_Count_Should_Match_Filter() { await using (TvContext context = _db.CreateContext()) { for (var i = 0; i < Names.Length; i++) { context.Channels.Add(NewChannel(i + 1, $"{i + 1}", Names[i])); context.Playouts.Add( new Playout { Id = i + 1, ChannelId = i + 1, ScheduleKind = PlayoutScheduleKind.Classic }); } // a playout whose channel row does not exist: excluded from the page by the // `Channel != null` filter, so it must be excluded from the count too context.Playouts.Add( new Playout { Id = 99, ChannelId = 4242, ScheduleKind = PlayoutScheduleKind.Classic }); await context.SaveChangesAsync(); } var handler = new GetPagedPlayoutsHandler(_db.Factory); PagedPlayoutsViewModel unfiltered = await handler.Handle(new GetPagedPlayouts(string.Empty, 0, 10), CancellationToken.None); // 5, NOT 6 — the orphaned playout is filtered out of the page, so it is not part of the total unfiltered.TotalCount.ShouldBe(SeededRows); unfiltered.Page.Count.ShouldBe(SeededRows); PagedPlayoutsViewModel filtered = await handler.Handle(new GetPagedPlayouts("Alpha", 0, 10), CancellationToken.None); filtered.TotalCount.ShouldBe(MatchingAlpha); filtered.Page.Select(p => p.ChannelName).ShouldBe(["Alpha One", "Alpha Two"]); } [Test] public async Task Filtered_Count_Should_Drive_A_Second_Page() { await using (TvContext context = _db.CreateContext()) { for (var i = 0; i < Names.Length; i++) { context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] }); } await context.SaveChangesAsync(); } var handler = new GetPagedCollectionsHandler(_db.Factory); // pageSize 1 over the 2 matching rows: this is the property the issues are about — the count // is what tells the client a SECOND page exists, and each page holds exactly its own row PagedMediaCollectionsViewModel first = await handler.Handle(new GetPagedCollections("Alpha", 0, 1), CancellationToken.None); first.TotalCount.ShouldBe(MatchingAlpha); first.Page.Select(c => c.Name).ShouldBe(["Alpha One"]); PagedMediaCollectionsViewModel second = await handler.Handle(new GetPagedCollections("Alpha", 1, 1), CancellationToken.None); second.TotalCount.ShouldBe(MatchingAlpha); second.Page.Select(c => c.Name).ShouldBe(["Alpha Two"]); // and the page AFTER the last matching row is empty — with the pre-fix count of 5 the client // would have been told to fetch three more pages that can never contain anything PagedMediaCollectionsViewModel past = await handler.Handle(new GetPagedCollections("Alpha", 2, 1), CancellationToken.None); past.TotalCount.ShouldBe(MatchingAlpha); past.Page.ShouldBeEmpty(); } private static DomainChannel NewChannel(int id, string number, string name) => new(Guid.NewGuid()) { Id = id, Number = number, SortNumber = id, Name = name, Group = "ErsatzTV", Categories = string.Empty, FFmpegProfileId = 1, StreamSelector = string.Empty, PreferredAudioLanguageCode = string.Empty, PreferredAudioTitle = string.Empty, PreferredSubtitleLanguageCode = string.Empty, MusicVideoCreditsTemplate = string.Empty, StreamingMode = StreamingMode.TransportStreamHybrid, PlayoutSource = ChannelPlayoutSource.Generated, PlayoutMode = ChannelPlayoutMode.Continuous }; }