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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
287 lines
11 KiB
C#
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
|
|
};
|
|
}
|