Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m33s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 36m5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
The guide/EPG grid (/app/guide) and the channels list (/app/channels) always drew the generated initials "bug" because the browse DTOs never carried a logo URL — GuideScreen/ChannelsScreen rendered <ChannelLogo> with no src. The logo data existed (it round-trips through the channel editor) but never reached these views. Add a rooted, directly-usable Logo URL to ChannelGuideChannelResponseModel and ChannelResponseModel, populated by a single Channels.Mapper.GetLogoUrl helper (#181 artwork convention): /iptv/logos/{file} for an uploaded logo, the absolute URL passed through for an external one, null when unset so the SPA keeps its generated-initials fallback. The guide query now includes Channel.Artwork. Regenerated OpenAPI + v1.d.ts; updated api-conventions.md + domain-model.md. fixes #464 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
339 lines
14 KiB
C#
339 lines
14 KiB
C#
using ErsatzTV.Application.Channels;
|
|
using ErsatzTV.Application.Configuration;
|
|
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using DomainChannel = ErsatzTV.Core.Domain.Channel;
|
|
|
|
namespace ErsatzTV.Tests.Application.Channels;
|
|
|
|
[TestFixture]
|
|
public class GetChannelGuideDataHandlerTests
|
|
{
|
|
private static readonly DateTime BaseTime = new(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc);
|
|
|
|
private InMemoryTvContext _db = null!;
|
|
private IConfigElementRepository _config = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp()
|
|
{
|
|
_db = await InMemoryTvContext.CreateAsync();
|
|
_config = Substitute.For<IConfigElementRepository>();
|
|
|
|
// ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we
|
|
// match by the generic value type (GetValue<T>) with Arg.Any key. Pin the time zone to UTC so
|
|
// projected times are deterministic regardless of the machine/CI time zone, and split block time
|
|
// evenly. XmltvDaysToBuild is left unconfigured (falls back to 2) except in the default-window test.
|
|
_config.GetValue<XmltvTimeZone>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<XmltvTimeZone>.Some(XmltvTimeZone.Utc));
|
|
_config.GetValue<XmltvBlockBehavior>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<XmltvBlockBehavior>.Some(XmltvBlockBehavior.SplitTimeEvenly));
|
|
}
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
private GetChannelGuideDataHandler MakeHandler() => new(_db.Factory, _config);
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Only_Include_Visible_Channels()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
DomainChannel visible = NewChannel("2", "Visible", showInEpg: true);
|
|
DomainChannel hidden = NewChannel("3", "Hidden", showInEpg: false);
|
|
visible.Playouts = [MakeFloodPlayout(visible, (BaseTime, BaseTime.AddHours(1), 1, "Visible Show"))];
|
|
hidden.Playouts = [MakeFloodPlayout(hidden, (BaseTime, BaseTime.AddHours(1), 1, "Hidden Show"))];
|
|
context.Channels.AddRange(visible, hidden);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
|
CancellationToken.None);
|
|
|
|
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Order_Channels_By_Decimal_Number()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
context.Channels.Add(NewChannel("10", "Ten", showInEpg: true));
|
|
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
|
|
context.Channels.Add(NewChannel("5.1", "FiveOne", showInEpg: true));
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
|
CancellationToken.None);
|
|
|
|
// decimal order: 2 < 5.1 < 10 (string order would put "10" first)
|
|
result.Channels.Select(c => c.Number).ShouldBe(["2", "5.1", "10"]);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Filter_Programmes_To_Requested_Window()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
|
channel.Playouts =
|
|
[
|
|
MakeFloodPlayout(
|
|
channel,
|
|
(BaseTime, BaseTime.AddHours(1), 1, "Before Window"),
|
|
(BaseTime.AddHours(10), BaseTime.AddHours(11), 2, "In Window"))
|
|
];
|
|
context.Channels.Add(channel);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
// window starts after the first programme has finished
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime.AddHours(5), BaseTime.AddHours(20)),
|
|
CancellationToken.None);
|
|
|
|
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
|
|
programmes.Select(p => p.Title).ShouldBe(["In Window"]);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Passthrough_Custom_Title_And_Null_SubTitle()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
|
PlayoutItem item = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Ignored");
|
|
item.CustomTitle = "Custom Title";
|
|
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [item])];
|
|
context.Channels.Add(channel);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
|
CancellationToken.None);
|
|
|
|
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
|
|
programme.Title.ShouldBe("Custom Title");
|
|
programme.SubTitle.ShouldBeNull();
|
|
programme.Category.ShouldBe("Movie");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Merge_Leading_PreRoll_Filler_Into_Following_Programme()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
|
PlayoutItem preRoll = MakeItem(BaseTime, BaseTime.AddMinutes(5), guideGroup: 1, movieTitle: "Bumper");
|
|
preRoll.FillerKind = FillerKind.PreRoll;
|
|
PlayoutItem content = MakeItem(BaseTime.AddMinutes(5), BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Feature");
|
|
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [preRoll, content])];
|
|
context.Channels.Add(channel);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
|
CancellationToken.None);
|
|
|
|
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
|
|
// filler is merged: the programme starts at the pre-roll start but displays the feature metadata
|
|
programme.Title.ShouldBe("Feature");
|
|
programme.Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
|
|
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
|
|
programme.FillerKind.ShouldBe(FillerKind.None);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Split_Block_Window_Evenly_Across_Content_Items()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
|
PlayoutItem one = MakeItem(BaseTime, BaseTime.AddMinutes(20), guideGroup: 7, movieTitle: "One");
|
|
PlayoutItem two = MakeItem(BaseTime.AddMinutes(20), BaseTime.AddMinutes(40), guideGroup: 7, movieTitle: "Two");
|
|
foreach (PlayoutItem item in new[] { one, two })
|
|
{
|
|
item.GuideStart = BaseTime;
|
|
item.GuideFinish = BaseTime.AddHours(1);
|
|
}
|
|
|
|
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Block, [one, two])];
|
|
context.Channels.Add(channel);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
|
CancellationToken.None);
|
|
|
|
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
|
|
programmes.Count.ShouldBe(2);
|
|
// 1-hour guide window split evenly across the 2 content items -> 30 minutes each
|
|
programmes[0].Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
|
|
programmes[0].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
|
|
programmes[1].Start.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
|
|
programmes[1].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Default_Start_To_Now_And_End_To_DaysToBuild()
|
|
{
|
|
_config.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<int>.Some(3));
|
|
|
|
DateTimeOffset before = DateTimeOffset.UtcNow;
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(null, null),
|
|
CancellationToken.None);
|
|
DateTimeOffset after = DateTimeOffset.UtcNow;
|
|
|
|
result.Start.ShouldBeInRange(before, after);
|
|
(result.End - result.Start).ShouldBe(TimeSpan.FromDays(3));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Shift_Mirror_Channel_Programmes_By_PlayoutOffset_Without_Mutating_Source_Items()
|
|
{
|
|
PlayoutItem sourceItem = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Live Show");
|
|
var playoutOffset = TimeSpan.FromHours(3);
|
|
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
DomainChannel source = NewChannel("1", "Source", showInEpg: false);
|
|
source.Playouts = [MakePlayout(source, PlayoutScheduleKind.Classic, [sourceItem])];
|
|
|
|
DomainChannel mirror = NewChannel("2", "Mirror", showInEpg: true);
|
|
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
|
mirror.MirrorSourceChannel = source;
|
|
mirror.PlayoutOffset = playoutOffset;
|
|
|
|
context.Channels.AddRange(source, mirror);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
|
CancellationToken.None);
|
|
|
|
ChannelGuideProgrammeResponseModel programme = result.Channels.Single(c => c.Number == "2").Programmes.Single();
|
|
programme.Title.ShouldBe("Live Show");
|
|
programme.Start.ShouldBe(new DateTimeOffset(BaseTime.Add(playoutOffset), TimeSpan.Zero));
|
|
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1).Add(playoutOffset), TimeSpan.Zero));
|
|
|
|
// the WithPlayoutOffset copy fix: applying the mirror offset must not mutate the source playout's
|
|
// own (shared, AsNoTracking) PlayoutItem entities in place.
|
|
sourceItem.Start.ShouldBe(BaseTime);
|
|
sourceItem.Finish.ShouldBe(BaseTime.AddHours(1));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Root_Uploaded_Logo_And_Null_When_Absent()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
DomainChannel withLogo = NewChannel("2", "WithLogo", showInEpg: true);
|
|
withLogo.Artwork = [new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "abc123.png" }];
|
|
DomainChannel withoutLogo = NewChannel("3", "NoLogo", showInEpg: true);
|
|
context.Channels.AddRange(withLogo, withoutLogo);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
|
CancellationToken.None);
|
|
|
|
// Uploaded logo is rooted for the SPA's <img src>; a channel with no logo returns null so the SPA
|
|
// renders its generated initials fallback.
|
|
result.Channels.Single(c => c.Number == "2").Logo.ShouldBe("/iptv/logos/abc123.png");
|
|
result.Channels.Single(c => c.Number == "3").Logo.ShouldBeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
|
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
|
CancellationToken.None);
|
|
|
|
result.Channels.Single().Programmes.ShouldBeEmpty();
|
|
}
|
|
|
|
// --- seeding helpers ---
|
|
|
|
private static Playout MakeFloodPlayout(
|
|
DomainChannel channel,
|
|
params (DateTime Start, DateTime Finish, int GuideGroup, string Title)[] items) =>
|
|
MakePlayout(
|
|
channel,
|
|
PlayoutScheduleKind.Classic,
|
|
items.Select(i => MakeItem(i.Start, i.Finish, i.GuideGroup, i.Title)).ToList());
|
|
|
|
private static Playout MakePlayout(
|
|
DomainChannel channel,
|
|
PlayoutScheduleKind scheduleKind,
|
|
List<PlayoutItem> items) =>
|
|
new()
|
|
{
|
|
Channel = channel,
|
|
ScheduleKind = scheduleKind,
|
|
ScheduleFile = string.Empty,
|
|
Items = items
|
|
};
|
|
|
|
private static PlayoutItem MakeItem(
|
|
DateTime start,
|
|
DateTime finish,
|
|
int guideGroup,
|
|
string movieTitle) =>
|
|
new()
|
|
{
|
|
Start = start,
|
|
Finish = finish,
|
|
GuideGroup = guideGroup,
|
|
FillerKind = FillerKind.None,
|
|
MediaItem = new Movie
|
|
{
|
|
MovieMetadata = [new MovieMetadata { Title = movieTitle }],
|
|
MediaVersions = []
|
|
}
|
|
};
|
|
|
|
private static DomainChannel 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,
|
|
PlayoutSource = ChannelPlayoutSource.Generated,
|
|
PlayoutMode = ChannelPlayoutMode.Continuous,
|
|
ShowInEpg = showInEpg,
|
|
Playouts = []
|
|
};
|
|
}
|