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>
168 lines
8.5 KiB
C#
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);
|
|
}
|