From 38e743d3cf5061936815e0b5cf98068fad444037 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 27 Jun 2026 13:51:30 +0200 Subject: [PATCH] test: golden tests for XMLTV guide output (refs #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the real GetChannelGuideHandler against a Testably fake filesystem (seeded channels.xml + per-channel fragments) and a SQLite-backed IDbContextFactory with one visible + one ShowInEpg=false channel, then snapshot ChannelGuide.ToXml(). Covers the {RequestBase}/{AccessTokenUri} substitution (the #1 regression surface), etv:-tag stripping, hidden-channel exclusion, and assembly — with/without access token and with a base URL. The volatile ?v={mtime} cache-buster is normalized. Same [CallerFilePath] Goldens + ETV_UPDATE_GOLDENS harness as the M3U goldens (#11). Core.Tests now references ErsatzTV.Application (handler lives there). Co-Authored-By: Claude Opus 4.8 --- .../ErsatzTV.Core.Tests.csproj | 1 + .../Iptv/ChannelGuideGoldenTests.cs | 233 ++++++++++++++++++ .../Iptv/Goldens/guide-base-url.xml | 1 + .../Iptv/Goldens/guide-no-token.xml | 1 + .../Iptv/Goldens/guide-with-token.xml | 1 + 5 files changed, 237 insertions(+) create mode 100644 ErsatzTV.Core.Tests/Iptv/ChannelGuideGoldenTests.cs create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/guide-base-url.xml create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/guide-no-token.xml create mode 100644 ErsatzTV.Core.Tests/Iptv/Goldens/guide-with-token.xml diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj index ac3a24095..867f66761 100644 --- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/ErsatzTV.Core.Tests/Iptv/ChannelGuideGoldenTests.cs b/ErsatzTV.Core.Tests/Iptv/ChannelGuideGoldenTests.cs new file mode 100644 index 000000000..b7502342a --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/ChannelGuideGoldenTests.cs @@ -0,0 +1,233 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using ErsatzTV.Application.Channels; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Iptv; +using ErsatzTV.Infrastructure; +using ErsatzTV.Infrastructure.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IO; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem; + +namespace ErsatzTV.Core.Tests.Iptv; + +// Golden-file tests that lock the XMLTV guide output GetChannelGuideHandler produces — the surface +// Jellyfin/clients consume for EPG + channel/programme artwork. The handler reads pre-built cache +// fragments (channels.xml + {number}.xml), substitutes {RequestBase}/{AccessTokenUri}, strips +// internal etv: tags, hides ShowInEpg=false channels, then assembles via ChannelGuide.ToXml(). +// +// This is the XMLTV counterpart to ChannelPlaylistGoldenTests (#11) and, like it, is the regression +// net for the {RequestBase} host substitution at the heart of #1. Goldens live under Goldens/ and are +// regenerated via the Regenerate_goldens test or ETV_UPDATE_GOLDENS=1 — review the diff before committing. +// +// The goldens faithfully enshrine current behaviour, including the raw unescaped "&text=" ampersand the +// real _channel.sbntxt template emits (technically not well-formed XML). That is why the comparison is +// string equality rather than XDocument.Parse — a parse guard would throw on today's legitimate output. +[TestFixture] +public class ChannelGuideGoldenTests +{ + private const string Scheme = "https"; + private const string Host = "tv.example.com"; + + // channels.xml is NOT filtered by ShowInEpg — every channel's def stays here; only the + // per-channel programme *data* fragment of a hidden channel is dropped. Channels 2 and 10 are visible, + // channel 3 is hidden. (2 vs 10 also proves ChannelGuide.ToXml orders fragments by DECIMAL, not string.) + private const string ChannelsXml = + """2 News2NewsGeneral10 Movies10Movies3 Hidden3Hidden"""; + + // Visible (channel 2) — exercises {RequestBase}/{AccessTokenUri} substitution on the programme artwork + // (which must SURVIVE) AND etv:-tag stripping. etv: nodes are the documented optional graphics- + // engine feature (paired like ); a trailing self- + // closing exercises the regex's second alternative. Both must be gone from client output. + private const string Channel2Xml = + """Morning NewsToday's headlines.News5"""; + + // Visible (channel 10) — uses an EXTERNAL icon URL with no placeholder, proving external URLs pass + // through verbatim. Its decimal key (10) must sort AFTER channel 2 despite string order putting "10" first. + private const string Channel10Xml = + """MatineeA film.Movie"""; + + // Hidden (channel 3, ShowInEpg=false) — its programme data must NOT appear in the output. + private const string Channel3Xml = + """Hidden Show"""; + + private SqliteConnection _connection; + private IDbContextFactory _dbContextFactory; + + [OneTimeSetUp] + public async Task SetUpDatabase() + { + // Shared in-memory SQLite: the connection must stay open for the DB to live across contexts. + // Foreign keys are disabled — we only read Channel.Number/ShowInEpg, so referential integrity + // (e.g. the FFmpegProfile FK) is irrelevant and would only complicate seeding. + _connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False"); + await _connection.OpenAsync(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _dbContextFactory = new TestTvContextFactory(options); + + await using TvContext context = _dbContextFactory.CreateDbContext(); + + // EnsureCreated builds the schema from the model directly — sufficient here and far cheaper than + // replaying every migration just to read two Channel columns. + await context.Database.EnsureCreatedAsync(); + await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;"); + + context.Channels.Add(NewChannel("2", "News", showInEpg: true)); + context.Channels.Add(NewChannel("10", "Movies", showInEpg: true)); + context.Channels.Add(NewChannel("3", "Hidden", showInEpg: false)); + await context.SaveChangesAsync(); + } + + [OneTimeTearDown] + public void TearDownDatabase() => _connection?.Dispose(); + + [Test] + public Task Guide_without_access_token() => + Verify("guide-no-token.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null)); + + [Test] + public Task Guide_with_access_token() => + Verify("guide-with-token.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: "SECRET-TOKEN")); + + [Test] + public Task Guide_with_base_url() => + Verify("guide-base-url.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "/etv", AccessToken: null)); + + // --- harness --- + + private async Task Verify(string goldenName, GetChannelGuide request) + { + MockFileSystem fileSystem = BuildCacheFileSystem(); + + // ListFiles returns channels.xml too (the handler skips it via Contains("channels")) and in a + // deliberately non-decimal order, so the test proves ChannelGuide.ToXml re-sorts by decimal key. + var localFileSystem = Substitute.For(); + localFileSystem + .ListFiles(FileSystemLayout.ChannelGuideCacheFolder) + .Returns(new[] + { + FragmentPath(fileSystem, "channels.xml"), + FragmentPath(fileSystem, "10.xml"), + FragmentPath(fileSystem, "3.xml"), + FragmentPath(fileSystem, "2.xml") + }); + + var handler = new GetChannelGuideHandler( + _dbContextFactory, + new RecyclableMemoryStreamManager(), + fileSystem, + localFileSystem); + + Either result = await handler.Handle(request, CancellationToken.None); + + string actual = Normalize( + result.Match( + Right: guide => guide.ToXml(), + Left: error => throw new AssertionException($"Handler returned error: {error.Value}"))); + + string path = Path.Combine(GoldenDir(), goldenName); + + if (Environment.GetEnvironmentVariable("ETV_UPDATE_GOLDENS") == "1") + { + Directory.CreateDirectory(GoldenDir()); + await File.WriteAllTextAsync(path, actual); + Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify."); + return; + } + + // A missing golden is a hard failure (not a silent skip) so an un-committed baseline can't pass CI. + File.Exists(path).ShouldBeTrue( + $"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_GOLDENS=1) and commit it."); + + string expected = Canonicalize(await File.ReadAllTextAsync(path)); + actual.ShouldBe(expected); + } + + [Test] + [Explicit("Regenerates all XMLTV goldens from current output; review the diff before committing.")] + public async Task Regenerate_goldens() + { + Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", "1"); + try + { + foreach (Func regen in new Func[] + { + Guide_without_access_token, Guide_with_access_token, Guide_with_base_url + }) + { + try + { + await regen(); + } + catch (InconclusiveException) + { + // expected — each Verify writes its golden then reports inconclusive + } + } + } + finally + { + Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", null); + } + } + + private static MockFileSystem BuildCacheFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.CreateDirectory(FileSystemLayout.ChannelGuideCacheFolder); + fileSystem.File.WriteAllText(FragmentPath(fileSystem, "channels.xml"), ChannelsXml); + fileSystem.File.WriteAllText(FragmentPath(fileSystem, "2.xml"), Channel2Xml); + fileSystem.File.WriteAllText(FragmentPath(fileSystem, "10.xml"), Channel10Xml); + fileSystem.File.WriteAllText(FragmentPath(fileSystem, "3.xml"), Channel3Xml); + return fileSystem; + } + + private static string FragmentPath(MockFileSystem fileSystem, string name) => + fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, name); + + // The XMLTV cache-buster (?v={channels.xml last-write ticks}) is the only volatile token; pin it so + // the golden is deterministic. Then canonicalize to a single trailing newline (Canonicalize) so the + // golden satisfies .editorconfig insert_final_newline and a formatting pass can't break the test. + private static string Normalize(string xml) => + Canonicalize(Regex.Replace(xml, @"\?v=\d+", "?v=MTIME")); + + // Strip the UTF-8 BOM that XmlWriter emits as a preamble (the load-bearing strip — the golden files + // themselves have no BOM), normalize line endings, and force exactly one trailing newline. + private static string Canonicalize(string xml) => + xml.TrimStart('').ReplaceLineEndings("\n").TrimEnd('\n') + "\n"; + + private static Channel NewChannel(string number, string name, bool showInEpg) => + new(Guid.NewGuid()) + { + Number = number, + Name = name, + Group = "ErsatzTV", + Categories = string.Empty, + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + ShowInEpg = showInEpg + }; + + private static string GoldenDir([CallerFilePath] string thisFile = "") => + Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens"); + + private sealed class TestTvContextFactory(DbContextOptions options) : IDbContextFactory + { + public TvContext CreateDbContext() => + new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger.Instance)); + } +} diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/guide-base-url.xml b/ErsatzTV.Core.Tests/Iptv/Goldens/guide-base-url.xml new file mode 100644 index 000000000..0700e6a71 --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/guide-base-url.xml @@ -0,0 +1 @@ +2 News2NewsGeneral10 Movies10Movies3 Hidden3HiddenMorning NewsToday's headlines.NewsMatineeA film.Movie diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/guide-no-token.xml b/ErsatzTV.Core.Tests/Iptv/Goldens/guide-no-token.xml new file mode 100644 index 000000000..5d1df2166 --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/guide-no-token.xml @@ -0,0 +1 @@ +2 News2NewsGeneral10 Movies10Movies3 Hidden3HiddenMorning NewsToday's headlines.NewsMatineeA film.Movie diff --git a/ErsatzTV.Core.Tests/Iptv/Goldens/guide-with-token.xml b/ErsatzTV.Core.Tests/Iptv/Goldens/guide-with-token.xml new file mode 100644 index 000000000..c3f62b76a --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/Goldens/guide-with-token.xml @@ -0,0 +1 @@ +2 News2NewsGeneral10 Movies10Movies3 Hidden3HiddenMorning NewsToday's headlines.NewsMatineeA film.Movie