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