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
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:
@@ -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&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&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&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&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>
|
||||
Reference in New Issue
Block a user