using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
///
/// Regression tests for #229. The write path (Replace = PUT, Add = POST) used to project freshly-persisted
/// items whose watermark/graphics join rows only carry foreign-key ids — the Watermark / GraphicsElement navs
/// are null, and Mapper.ProjectToViewModel dereferences them without a guard, throwing an NRE that
/// surfaced as a 500. The pre-existing round-trip test masked this because the Replace handler returned a
/// lazy LanguageExt Map sequence and that test only checked .IsRight — it never enumerated
/// the projected items, so the deferred NRE never fired. These tests force enumeration of the write-path
/// response (exactly as the controller's .ToList() / single-item serialization does) and seed the
/// watermark/graphics entities through a separate context so the handler's fresh factory context has
/// nothing pre-tracked (no accidental change-tracker fix-up).
///
[TestFixture]
public class ScheduleItemWriteProjectionTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Replace_PutResponse_Should_Carry_Watermark_And_Graphics_Names()
{
int scheduleId = await SeedReferences();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either> result = await handler.Handle(
new ReplaceProgramScheduleItems(scheduleId, [ItemWithGraphics(0)]),
CancellationToken.None);
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
// Force enumeration exactly like the controller (`items.Select(...).ToList()`) — this is what threw the NRE.
List items = result.RightToSeq().Head().ToList();
ProgramScheduleItemViewModel item = items.Single();
item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
item.GraphicsElements.Count.ShouldBe(2);
item.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));
}
[Test]
public async Task Add_PostResponse_Should_Carry_Watermark_And_Graphics_Names()
{
int scheduleId = await SeedReferences();
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
Either result = await handler.Handle(
AddItemWithGraphics(scheduleId),
CancellationToken.None);
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
ProgramScheduleItemViewModel item = result.RightToSeq().Head();
item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
item.GraphicsElements.Count.ShouldBe(2);
}
[Test]
public async Task Get_Should_Return_Items_In_Index_Order_Regardless_Of_Id_Order()
{
int scheduleId;
await using (TvContext ctx = _db.CreateContext())
{
var schedule = new ProgramSchedule
{
Name = "Shuffled", Items = [], Playouts = [], ProgramScheduleAlternates = []
};
ctx.ProgramSchedules.Add(schedule);
await ctx.SaveChangesAsync();
scheduleId = schedule.Id;
// Insert with ids deliberately out of Index order: id 10 -> Index 2, id 11 -> Index 0, id 12 -> Index 1.
// Before the fix the GET returned id order (2, 0, 1); after, it must return Index order (0, 1, 2).
ctx.ProgramScheduleItems.Add(OneItemRow(10, scheduleId, index: 2));
ctx.ProgramScheduleItems.Add(OneItemRow(11, scheduleId, index: 0));
ctx.ProgramScheduleItems.Add(OneItemRow(12, scheduleId, index: 1));
await ctx.SaveChangesAsync();
}
var getHandler = new GetProgramScheduleItemsHandler(_db.Factory);
List items =
await getHandler.Handle(new GetProgramScheduleItems(scheduleId), CancellationToken.None);
items.Select(i => i.Index).ShouldBe([0, 1, 2]);
}
private static ProgramScheduleItemOne OneItemRow(int id, int scheduleId, int index) =>
new()
{
Id = id,
ProgramScheduleId = scheduleId,
Index = index,
CollectionType = CollectionType.Collection,
CollectionId = 1,
PlaybackOrder = PlaybackOrder.Shuffle,
ProgramScheduleItemWatermarks = [],
ProgramScheduleItemGraphicsElements = []
};
private async Task SeedReferences()
{
await using TvContext ctx = _db.CreateContext();
ctx.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
ctx.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
ctx.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });
var schedule = new ProgramSchedule
{
Name = "WM Schedule", Items = [], Playouts = [], ProgramScheduleAlternates = []
};
ctx.ProgramSchedules.Add(schedule);
await ctx.SaveChangesAsync();
return schedule.Id;
}
private static ReplaceProgramScheduleItem ItemWithGraphics(int index) =>
new(
null, index, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One,
CollectionType.Collection, CollectionId: 1, MultiCollectionId: null, SmartCollectionId: null,
RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: null, SearchQuery: null,
PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: null,
PlayoutDuration: null, TailMode: TailMode.None, DiscardToFillAttempts: null, CustomTitle: null,
GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null,
TailFillerId: null, FallbackFillerId: null, WatermarkIds: [1, 2], GraphicsElementIds: [1, 2],
PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
private static AddProgramScheduleItem AddItemWithGraphics(int scheduleId) =>
new(
scheduleId, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One,
CollectionType.Collection, CollectionId: 1, MultiCollectionId: null, SmartCollectionId: null,
RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: null, SearchQuery: null,
PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: null,
PlayoutDuration: null, TailMode: TailMode.None, DiscardToFillAttempts: null, CustomTitle: null,
GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null,
TailFillerId: null, FallbackFillerId: null, WatermarkIds: [1, 2], GraphicsElementIds: [1, 2],
PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
}