test: golden tests for XMLTV guide output (refs #28)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m30s

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 <noreply@anthropic.com>
This commit was merged in pull request #40.
This commit is contained in:
2026-06-27 14:04:45 +02:00
co-authored by Claude Opus 4.8
parent 7dfd4c37bb
commit 38e743d3cf
5 changed files with 237 additions and 0 deletions
@@ -26,6 +26,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Application\ErsatzTV.Application.csproj" />
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure.Sqlite\ErsatzTV.Infrastructure.Sqlite.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
@@ -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 <channel> 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 =
"""<channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="{RequestBase}/iptv/logos/news.jpg{AccessTokenUri}" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="{RequestBase}/iptv/logos/gen{AccessTokenUri}&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="{RequestBase}/iptv/logos/hidden.jpg{AccessTokenUri}" /></channel>""";
// Visible (channel 2) — exercises {RequestBase}/{AccessTokenUri} substitution on the programme artwork
// <icon> (which must SURVIVE) AND etv:-tag stripping. etv: nodes are the documented optional graphics-
// engine feature (paired like <etv:episode_number_key>…</etv:episode_number_key>); a trailing self-
// closing <etv:…/> exercises the regex's second alternative. Both must be gone from client output.
private const string Channel2Xml =
"""<programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="{RequestBase}/iptv/artwork/posters/abc.jpg{AccessTokenUri}" /><etv:episode_number_key>5</etv:episode_number_key><etv:marker /><previously-shown /></programme>""";
// 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 =
"""<programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme>""";
// Hidden (channel 3, ShowInEpg=false) — its programme data must NOT appear in the output.
private const string Channel3Xml =
"""<programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C3.etv"><title lang="en">Hidden Show</title></programme>""";
private SqliteConnection _connection;
private IDbContextFactory<TvContext> _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<TvContext> options = new DbContextOptionsBuilder<TvContext>()
.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<ILocalFileSystem>();
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<BaseError, ChannelGuide> 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<Task> regen in new Func<Task>[]
{
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<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() =>
new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
}
}
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/etv/iptv/logos/news.jpg?v=MTIME" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/etv/iptv/logos/gen?v=MTIME&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/etv/iptv/logos/hidden.jpg?v=MTIME" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/etv/iptv/artwork/posters/abc.jpg?v=MTIME" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/iptv/logos/news.jpg?v=MTIME" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/iptv/logos/gen?v=MTIME&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/iptv/logos/hidden.jpg?v=MTIME" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/iptv/artwork/posters/abc.jpg?v=MTIME" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/iptv/logos/news.jpg?v=MTIME&amp;access_token=SECRET-TOKEN" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/iptv/logos/gen?v=MTIME&amp;access_token=SECRET-TOKEN&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/iptv/logos/hidden.jpg?v=MTIME&amp;access_token=SECRET-TOKEN" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/iptv/artwork/posters/abc.jpg?v=MTIME&amp;access_token=SECRET-TOKEN" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>