Files
ersatztv/ErsatzTV.Tests/Application/Paging/PagedQueryTotalCountTests.cs
T
timothyandtimothy 08cd3a002d
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m40s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m46s
fix(690,758): count the same query a paged handler pages (#833)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 07:39:23 +00:00

287 lines
11 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[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
};
}