Files
ersatztv/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemWriteProjectionTests.cs
T
timothyandClaude Opus 4.8 162b334e5d test(#259): id-based reconcile matrix + docs (api-conventions §7c, decisions)
Add the id-based reconcile tests to ReplaceProgramScheduleItemsReconcileTests:
reorder moves state with the logical item (the non-vacuous core — proven to fail
under forced-positional), insert-in-middle, delete-unreferenced, unknown-id→422,
duplicate-id→422, and stale-version+unknown-id→412 (412 precedes 422, §7c). The
GET→map→PUT lossless round-trip now round-trips r.Id so it exercises id-mode.
Threads the new int? Id through all command/wire construction sites in tests.

Docs: api-conventions §7c (stable child identity + the deliberate #2-#5 positional
asymmetry) and a decisions.md entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:53:50 +02:00

168 lines
8.5 KiB
C#

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;
/// <summary>
/// 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 <c>Mapper.ProjectToViewModel</c> 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
/// <b>lazy</b> LanguageExt <c>Map</c> sequence and that test only checked <c>.IsRight</c> — 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 <c>.ToList()</c> / single-item serialization does) and seed the
/// watermark/graphics entities through a <b>separate</b> context so the handler's fresh factory context has
/// nothing pre-tracked (no accidental change-tracker fix-up).
/// </summary>
[TestFixture]
public class ScheduleItemWriteProjectionTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().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<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<ProgramScheduleItemViewModel> 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<BaseError, ProgramScheduleItemViewModel> 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<ProgramScheduleItemViewModel> 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<int> 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);
}