Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m45s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com>
372 lines
18 KiB
C#
372 lines
18 KiB
C#
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.Interfaces.Repositories;
|
||
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));
|
||
|
||
// When an advertised base URL is configured (issue #340), {RequestBase} must use it instead of the
|
||
// request-derived scheme/host — proving the override reaches both fragment substitution sites.
|
||
[Test]
|
||
public async Task Guide_uses_advertised_base_url_when_configured()
|
||
{
|
||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||
localFileSystem
|
||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||
.Returns(new[]
|
||
{
|
||
FragmentPath(fileSystem, "channels.xml"),
|
||
FragmentPath(fileSystem, "2.xml")
|
||
});
|
||
|
||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||
configElementRepository
|
||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||
.Returns(Option<string>.Some("https://public.example.com/etv"));
|
||
|
||
var handler = new GetChannelGuideHandler(
|
||
_dbContextFactory,
|
||
new RecyclableMemoryStreamManager(),
|
||
fileSystem,
|
||
localFileSystem,
|
||
configElementRepository);
|
||
|
||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
|
||
CancellationToken.None);
|
||
|
||
string xml = result.Match(
|
||
Right: guide => guide.ToXml(),
|
||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||
|
||
// The advertised origin replaces {RequestBase} on both the channel <icon> and the programme <icon>.
|
||
xml.ShouldContain("https://public.example.com/etv/iptv/logos/news.jpg");
|
||
xml.ShouldContain("https://public.example.com/etv/iptv/artwork/posters/abc.jpg");
|
||
xml.ShouldNotContain(Host);
|
||
}
|
||
|
||
// A configured base URL whose path prefix contains an XML-special character ('&' is a legal URL
|
||
// path char, so it passes AdvertisedBaseUrl validation) must be XML-escaped when substituted into
|
||
// the guide fragments — otherwise a bare '&' malforms the whole document. (Reviewer finding, #340.)
|
||
[Test]
|
||
public async Task Guide_xml_escapes_advertised_base_url()
|
||
{
|
||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||
localFileSystem
|
||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||
.Returns(new[]
|
||
{
|
||
FragmentPath(fileSystem, "channels.xml"),
|
||
FragmentPath(fileSystem, "2.xml")
|
||
});
|
||
|
||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||
configElementRepository
|
||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||
.Returns(Option<string>.Some("https://tv.example.com/a&b"));
|
||
|
||
var handler = new GetChannelGuideHandler(
|
||
_dbContextFactory,
|
||
new RecyclableMemoryStreamManager(),
|
||
fileSystem,
|
||
localFileSystem,
|
||
configElementRepository);
|
||
|
||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
|
||
CancellationToken.None);
|
||
|
||
string xml = result.Match(
|
||
Right: guide => guide.ToXml(),
|
||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||
|
||
// The '&' from the base URL must be emitted as '&', never a bare '&'.
|
||
xml.ShouldContain("https://tv.example.com/a&b/iptv/logos/news.jpg");
|
||
xml.ShouldNotContain("a&b");
|
||
}
|
||
|
||
// The access-token value is HTTP-request-derived (?access_token=) and interpolated into the
|
||
// {AccessTokenUri} placeholder, which sits in a URL query value inside an XML attribute. It is
|
||
// percent-encoded FIRST (#421 — URL-correct: a token '&' becomes %26 so it can't split the query and
|
||
// truncate the token once a consumer URL-decodes the attribute) and XML-escaped SECOND (#376 — so the
|
||
// guide stays well-formed). For this token every char percent-encodes to an XML-safe %XX, so the emitted
|
||
// value is the percent-encoded form with no '&'/'<' introduced by the token.
|
||
[Test]
|
||
public async Task Guide_encodes_and_xml_escapes_access_token()
|
||
{
|
||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||
localFileSystem
|
||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||
.Returns(new[]
|
||
{
|
||
FragmentPath(fileSystem, "channels.xml"),
|
||
FragmentPath(fileSystem, "2.xml")
|
||
});
|
||
|
||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||
configElementRepository
|
||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||
.Returns(Option<string>.None);
|
||
|
||
var handler = new GetChannelGuideHandler(
|
||
_dbContextFactory,
|
||
new RecyclableMemoryStreamManager(),
|
||
fileSystem,
|
||
localFileSystem,
|
||
configElementRepository);
|
||
|
||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: "tok&<>\""),
|
||
CancellationToken.None);
|
||
|
||
string xml = result.Match(
|
||
Right: guide => guide.ToXml(),
|
||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||
|
||
// Percent-encoded, therefore already XML-safe: '&'->%26, '<'->%3C, '>'->%3E, '"'->%22.
|
||
xml.ShouldContain("access_token=tok%26%3C%3E%22");
|
||
// The token must not introduce a bare '&' NOR an '&' — either would truncate the query on decode.
|
||
xml.ShouldNotContain("access_token=tok&");
|
||
// And the raw special chars must never reach the output.
|
||
xml.ShouldNotContain("tok&<>\"");
|
||
}
|
||
|
||
// --- 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")
|
||
});
|
||
|
||
// No advertised base URL configured — the {RequestBase} substitution must use the request-derived
|
||
// scheme/host/base, keeping today's output byte-for-byte identical (issue #340).
|
||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||
configElementRepository
|
||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||
.Returns(Option<string>.None);
|
||
|
||
var handler = new GetChannelGuideHandler(
|
||
_dbContextFactory,
|
||
new RecyclableMemoryStreamManager(),
|
||
fileSystem,
|
||
localFileSystem,
|
||
configElementRepository);
|
||
|
||
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));
|
||
}
|
||
}
|