@@ -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 ) ) ;
}
}