using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using Microsoft.EntityFrameworkCore; using NUnit.Framework; using Shouldly; using static LanguageExt.Prelude; namespace ErsatzTV.Tests.Application.ProgramSchedules; /// /// Regression tests for #252: the ReplaceProgramScheduleItems write path used to delete every existing /// item and re-insert with fresh ids, which cascade-deleted /// (FK OnDelete(Cascade)) — silently resetting persisted fill-group/shuffle progression on every save, /// even a no-op PUT-back. The handler now reconciles in place, keeping the item id (and its fill-group /// index) for a same-typed slot. The primary assertion is on the mechanism — item ids stay stable across /// a no-op save — because the in-memory harness runs with foreign_keys=OFF, so an assertion on the cascade /// alone would be FK-pragma dependent. /// [TestFixture] public class ReplaceProgramScheduleItemsReconcileTests { 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 NoOp_Replace_Should_Preserve_Item_Ids_And_FillGroup_State() { int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); var items = new List { MakeItem(0, PlayoutMode.Multiple, "group") with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups }, MakeItem(1, PlayoutMode.One, "news") }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None)) .IsRight.ShouldBeTrue(); List originalIds; int fillGroupItemId; await using (TvContext ctx = _db.CreateContext()) { List persisted = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId) .OrderBy(i => i.Index) .ToListAsync(); originalIds = persisted.Select(i => i.Id).ToList(); fillGroupItemId = persisted[0].Id; // persisted fill-group enumerator state pointing at item 0 (foreign_keys=OFF, so no Playout row needed) ctx.Add(new PlayoutScheduleItemFillGroupIndex { PlayoutId = 1, ProgramScheduleItemId = fillGroupItemId, EnumeratorState = new CollectionEnumeratorState { Seed = 12345, Index = 7 } }); await ctx.SaveChangesAsync(); } // Act: PUT the identical items back — a no-op save. (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None)) .IsRight.ShouldBeTrue(); await using (TvContext ctx = _db.CreateContext()) { List afterIds = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId) .OrderBy(i => i.Index) .Select(i => i.Id) .ToListAsync(); afterIds.ShouldBe(originalIds); PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set() .Include(x => x.EnumeratorState) .SingleAsync(); fillGroup.ProgramScheduleItemId.ShouldBe(fillGroupItemId); fillGroup.EnumeratorState.Seed.ShouldBe(12345); fillGroup.EnumeratorState.Index.ShouldBe(7); } } [Test] public async Task Reorder_Should_Preserve_Untouched_Item_Id_And_FillGroup_State() { int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); var items = new List { MakeItem(0, PlayoutMode.One, "a"), MakeItem(1, PlayoutMode.One, "b"), MakeItem(2, PlayoutMode.Multiple, "c") with { FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups } }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None)) .IsRight.ShouldBeTrue(); List originalIds; int untouchedItemId; await using (TvContext ctx = _db.CreateContext()) { List persisted = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId) .OrderBy(i => i.Index) .ToListAsync(); originalIds = persisted.Select(i => i.Id).OrderBy(id => id).ToList(); untouchedItemId = persisted[2].Id; // the item at index 2 ("c") is not reordered below ctx.Add(new PlayoutScheduleItemFillGroupIndex { PlayoutId = 1, ProgramScheduleItemId = untouchedItemId, EnumeratorState = new CollectionEnumeratorState { Seed = 99, Index = 4 } }); await ctx.SaveChangesAsync(); } // Act: swap the first two items; the third ("c") is unchanged and stays at index 2. var reordered = new List { items[1] with { Index = 0 }, items[0] with { Index = 1 }, items[2] }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, reordered), CancellationToken.None)) .IsRight.ShouldBeTrue(); await using (TvContext ctx = _db.CreateContext()) { // no item row was cascade-deleted: the full id set is intact List afterIds = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId) .Select(i => i.Id) .OrderBy(id => id) .ToListAsync(); afterIds.ShouldBe(originalIds); // the untouched item kept its id and its fill-group state PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set() .Include(x => x.EnumeratorState) .SingleAsync(); fillGroup.ProgramScheduleItemId.ShouldBe(untouchedItemId); fillGroup.EnumeratorState.Index.ShouldBe(4); } } [Test] public async Task InPlace_Update_Should_Still_Apply_Watermark_Edits() { int scheduleId = await SeedSchedule(); await using (TvContext ctx = _db.CreateContext()) { // the response projection hydrates watermark navs, so the referenced rows must exist ctx.Add(new ChannelWatermark { Id = 1, Name = "WM One" }); ctx.Add(new ChannelWatermark { Id = 2, Name = "WM Two" }); await ctx.SaveChangesAsync(); } var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); var initial = new List { MakeItem(0, PlayoutMode.One, "a") with { WatermarkIds = [1] } }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, initial), CancellationToken.None)) .IsRight.ShouldBeTrue(); int itemId; await using (TvContext ctx = _db.CreateContext()) { itemId = await ctx.ProgramScheduleItems.Where(i => i.ProgramScheduleId == scheduleId).Select(i => i.Id).SingleAsync(); } // change only the watermark set; same slot, same subtype -> in-place update var edited = new List { MakeItem(0, PlayoutMode.One, "a") with { WatermarkIds = [2] } }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, edited), CancellationToken.None)) .IsRight.ShouldBeTrue(); await using (TvContext ctx = _db.CreateContext()) { ProgramScheduleItem persisted = await ctx.ProgramScheduleItems .Include(i => i.ProgramScheduleItemWatermarks) .Where(i => i.ProgramScheduleId == scheduleId) .SingleAsync(); persisted.Id.ShouldBe(itemId); // in-place: id preserved persisted.ProgramScheduleItemWatermarks.Select(w => w.WatermarkId).ShouldBe([2]); // edit applied, old removed } } [Test] public async Task Subtype_Change_Should_Replace_The_Item() { int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); var initial = new List { MakeItem(0, PlayoutMode.One, "a") }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, initial), CancellationToken.None)) .IsRight.ShouldBeTrue(); int originalId; await using (TvContext ctx = _db.CreateContext()) { originalId = await ctx.ProgramScheduleItems.Where(i => i.ProgramScheduleId == scheduleId).Select(i => i.Id).SingleAsync(); } // EF cannot change a TPH row's type in place, so a mode change replaces the item (new id). var changed = new List { MakeItem(0, PlayoutMode.Duration, "a") with { PlayoutDuration = TimeSpan.FromMinutes(30), DiscardToFillAttempts = 0 } }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, changed), CancellationToken.None)) .IsRight.ShouldBeTrue(); await using (TvContext ctx = _db.CreateContext()) { ProgramScheduleItem persisted = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId) .SingleAsync(); persisted.ShouldBeOfType(); persisted.Id.ShouldNotBe(originalId); } } [Test] public async Task Reorder_ById_Should_Move_State_With_The_Logical_Item_Not_The_Slot() { // The non-vacuous core of #259: two SAME-typed items are swapped while round-tripping their ids. // In id-mode each item is matched by id, so idA MOVES to index 1 and keeps its own fill-group state. // In the old positional reconcile idA would have been reused in place at index 0 (its slot), silently // pairing item "a"'s enumerator state with item "b"'s content — so `idA.Index == 1` is the assertion // that fails under positional and passes under id-based reconcile. int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); var seedItems = new List { MakeItem(0, PlayoutMode.Multiple, "a") with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups }, MakeItem(1, PlayoutMode.Multiple, "b") with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups } }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, seedItems), CancellationToken.None)) .IsRight.ShouldBeTrue(); int idA, idB; await using (TvContext ctx = _db.CreateContext()) { List persisted = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).OrderBy(i => i.Index).ToListAsync(); idA = persisted[0].Id; idB = persisted[1].Id; AddFillGroup(ctx, idA, seed: 100); AddFillGroup(ctx, idB, seed: 200); await ctx.SaveChangesAsync(); } // Act: swap the two items, round-tripping their ids. var swapped = new List { MakeItem(0, PlayoutMode.Multiple, "b", id: idB) with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups }, MakeItem(1, PlayoutMode.Multiple, "a", id: idA) with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups } }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, swapped), CancellationToken.None)) .IsRight.ShouldBeTrue(); await using (TvContext ctx = _db.CreateContext()) { List after = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).ToListAsync(); after.Count.ShouldBe(2); // identity mapping: each id moved to its new position (positional would have kept idA at 0) after.Single(i => i.Id == idA).Index.ShouldBe(1); after.Single(i => i.Id == idB).Index.ShouldBe(0); // each item's fill-group state followed its own id — no misattribution, no cascade List groups = await ctx.Set() .Include(x => x.EnumeratorState).ToListAsync(); groups.Single(g => g.ProgramScheduleItemId == idA).EnumeratorState.Seed.ShouldBe(100); groups.Single(g => g.ProgramScheduleItemId == idB).EnumeratorState.Seed.ShouldBe(200); } } [Test] public async Task Insert_ById_In_Middle_Should_Keep_Existing_Ids_And_State() { int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); var seed = new List { MakeItem(0, PlayoutMode.One, "a"), MakeItem(1, PlayoutMode.One, "b") }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, seed), CancellationToken.None)) .IsRight.ShouldBeTrue(); int idA, idB; await using (TvContext ctx = _db.CreateContext()) { List p = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).OrderBy(i => i.Index).ToListAsync(); idA = p[0].Id; idB = p[1].Id; AddFillGroup(ctx, idA, seed: 111); AddFillGroup(ctx, idB, seed: 222); await ctx.SaveChangesAsync(); } // insert a brand-new (id-less) item between the two existing ones var withInsert = new List { MakeItem(0, PlayoutMode.One, "a", id: idA), MakeItem(1, PlayoutMode.One, "new"), MakeItem(2, PlayoutMode.One, "b", id: idB) }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, withInsert), CancellationToken.None)) .IsRight.ShouldBeTrue(); await using (TvContext ctx = _db.CreateContext()) { List after = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).ToListAsync(); after.Count.ShouldBe(3); after.Single(i => i.Id == idA).Index.ShouldBe(0); after.Single(i => i.Id == idB).Index.ShouldBe(2); // pushed down by the inserted middle item List groups = await ctx.Set() .Include(x => x.EnumeratorState).ToListAsync(); groups.Single(g => g.ProgramScheduleItemId == idA).EnumeratorState.Seed.ShouldBe(111); groups.Single(g => g.ProgramScheduleItemId == idB).EnumeratorState.Seed.ShouldBe(222); } } [Test] public async Task Delete_ById_Should_Remove_Unreferenced_And_Keep_Survivor_State() { int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); var seed = new List { MakeItem(0, PlayoutMode.One, "a"), MakeItem(1, PlayoutMode.One, "b") }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, seed), CancellationToken.None)) .IsRight.ShouldBeTrue(); int idA; await using (TvContext ctx = _db.CreateContext()) { List p = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).OrderBy(i => i.Index).ToListAsync(); idA = p[0].Id; AddFillGroup(ctx, idA, seed: 333); await ctx.SaveChangesAsync(); } // PUT only item A (by id) — B is no longer referenced and must be deleted var onlyA = new List { MakeItem(0, PlayoutMode.One, "a", id: idA) }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, onlyA), CancellationToken.None)) .IsRight.ShouldBeTrue(); await using (TvContext ctx = _db.CreateContext()) { List after = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).ToListAsync(); after.Count.ShouldBe(1); after.Single().Id.ShouldBe(idA); // survivor kept its id and its fill-group state (no cascade on the item that stayed) PlayoutScheduleItemFillGroupIndex survivor = await ctx.Set() .Include(x => x.EnumeratorState).SingleAsync(g => g.ProgramScheduleItemId == idA); survivor.EnumeratorState.Seed.ShouldBe(333); } } [Test] public async Task Unknown_Id_Should_Return_422_And_Persist_Nothing() { int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); (await handler.Handle( new ReplaceProgramScheduleItems(scheduleId, [MakeItem(0, PlayoutMode.One, "a")]), CancellationToken.None)) .IsRight.ShouldBeTrue(); (int idA, int versionBefore) = await ReadSingle(scheduleId); // a request id that doesn't belong to this schedule is rejected (stale/foreign identity) var bad = new List { MakeItem(0, PlayoutMode.One, "x", id: 999999) }; Either> result = await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, bad), CancellationToken.None); BaseError error = LeftOrThrow(result); error.GetType().ShouldBe(typeof(BaseError)); // exactly BaseError (→422), not PreconditionFailedError (→412) error.ToString().ShouldContain("999999"); await AssertUnchanged(scheduleId, idA, versionBefore); } [Test] public async Task Duplicate_Id_Should_Return_422_And_Persist_Nothing() { int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); (await handler.Handle( new ReplaceProgramScheduleItems(scheduleId, [MakeItem(0, PlayoutMode.One, "a")]), CancellationToken.None)) .IsRight.ShouldBeTrue(); (int idA, int versionBefore) = await ReadSingle(scheduleId); // the same id twice in one payload would map two request items onto one row — reject var dup = new List { MakeItem(0, PlayoutMode.One, "a", id: idA), MakeItem(1, PlayoutMode.One, "a-again", id: idA) }; Either> result = await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, dup), CancellationToken.None); BaseError error = LeftOrThrow(result); error.GetType().ShouldBe(typeof(BaseError)); error.ToString().ShouldContain(idA.ToString()); await AssertUnchanged(scheduleId, idA, versionBefore); } [Test] public async Task Stale_Version_And_Unknown_Id_Should_Return_412_Not_422() { // Ordering ruling (§7c): the concurrency pre-check runs before the id guards, so a client that is // BOTH version-stale and id-stale gets 412 (reload signal), never 422 (which reads as a payload bug). int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); (await handler.Handle( new ReplaceProgramScheduleItems(scheduleId, [MakeItem(0, PlayoutMode.One, "a")]), CancellationToken.None)) .IsRight.ShouldBeTrue(); (int idA, int versionBefore) = await ReadSingle(scheduleId); var bad = new List { MakeItem(0, PlayoutMode.One, "x", id: 999999) }; Either> result = await handler.Handle( new ReplaceProgramScheduleItems(scheduleId, bad, ExpectedVersions: Some(new[] { versionBefore + 1 }.ToSeq())), CancellationToken.None); LeftOrThrow(result).GetType().ShouldBe(typeof(PreconditionFailedError)); await AssertUnchanged(scheduleId, idA, versionBefore); } [Test] public async Task Subtype_Change_ById_Should_Replace_That_Item_But_Preserve_A_SameType_Sibling() { // id-mode counterpart of the positional Subtype_Change test: in ONE payload, one id-matched item // changes subtype (One -> Duration: delete+insert, new id, state resets) while a sibling id-matched // item keeps its subtype (Multiple: in-place, id + fill-group state preserved). This is the riskiest // reconcile branch — it exercises the delete pass + match-pass Remove/Add without double-handling. int scheduleId = await SeedSchedule(); var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); var seed = new List { MakeItem(0, PlayoutMode.One, "a"), MakeItem(1, PlayoutMode.Multiple, "b") with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups } }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, seed), CancellationToken.None)) .IsRight.ShouldBeTrue(); int idA, idB; await using (TvContext ctx = _db.CreateContext()) { List p = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).OrderBy(i => i.Index).ToListAsync(); idA = p[0].Id; idB = p[1].Id; AddFillGroup(ctx, idB, seed: 500); // fill-group state on the same-type sibling await ctx.SaveChangesAsync(); } // A (idA) changes subtype One -> Duration; B (idB) stays Multiple. var changed = new List { MakeItem(0, PlayoutMode.Duration, "a", id: idA) with { PlayoutDuration = TimeSpan.FromMinutes(30), DiscardToFillAttempts = 0 }, MakeItem(1, PlayoutMode.Multiple, "b", id: idB) with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups } }; (await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, changed), CancellationToken.None)) .IsRight.ShouldBeTrue(); await using (TvContext ctx = _db.CreateContext()) { List after = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).ToListAsync(); after.Count.ShouldBe(2); // A was replaced: old id gone, a NEW Duration row (distinct id) sits at index 0 after.ShouldNotContain(i => i.Id == idA); ProgramScheduleItem duration = after.Single(i => i is ProgramScheduleItemDuration); duration.Id.ShouldNotBe(idA); duration.Id.ShouldNotBe(idB); duration.Index.ShouldBe(0); // B was updated in place: same id, still Multiple, its fill-group state intact ProgramScheduleItem b = after.Single(i => i.Id == idB); b.ShouldBeOfType(); b.Index.ShouldBe(1); PlayoutScheduleItemFillGroupIndex bState = await ctx.Set() .Include(x => x.EnumeratorState).SingleAsync(g => g.ProgramScheduleItemId == idB); bState.EnumeratorState.Seed.ShouldBe(500); } } private static void AddFillGroup(TvContext ctx, int itemId, int seed) => ctx.Add(new PlayoutScheduleItemFillGroupIndex { PlayoutId = 1, ProgramScheduleItemId = itemId, EnumeratorState = new CollectionEnumeratorState { Seed = seed, Index = 0 } }); private static BaseError LeftOrThrow(Either> result) => result.Match(Left: e => e, Right: _ => throw new ShouldAssertException("expected a Left error")); // read the single seeded item's id and the schedule's current version private async Task<(int Id, int Version)> ReadSingle(int scheduleId) { await using TvContext ctx = _db.CreateContext(); int id = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).Select(i => i.Id).SingleAsync(); int version = await ctx.ProgramSchedules .Where(s => s.Id == scheduleId).Select(s => s.Version).SingleAsync(); return (id, version); } // a rejected replace must leave the schedule exactly as it was (no partial write, no version bump) private async Task AssertUnchanged(int scheduleId, int expectedItemId, int expectedVersion) { await using TvContext ctx = _db.CreateContext(); List ids = await ctx.ProgramScheduleItems .Where(i => i.ProgramScheduleId == scheduleId).Select(i => i.Id).ToListAsync(); ids.ShouldBe([expectedItemId]); int version = await ctx.ProgramSchedules .Where(s => s.Id == scheduleId).Select(s => s.Version).SingleAsync(); version.ShouldBe(expectedVersion); } private async Task SeedSchedule() { await using TvContext ctx = _db.CreateContext(); var schedule = new ProgramSchedule { Name = "Reconcile", Items = [], Playouts = [], ProgramScheduleAlternates = [] }; ctx.ProgramSchedules.Add(schedule); await ctx.SaveChangesAsync(); return schedule.Id; } private static ReplaceProgramScheduleItem MakeItem(int index, PlayoutMode mode, string searchQuery, int? id = null) => new( id, index, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, mode, CollectionType.SearchQuery, CollectionId: null, MultiCollectionId: null, SmartCollectionId: null, RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: searchQuery, SearchQuery: searchQuery, PlaybackOrder.Shuffle, MarathonGroupBy.None, MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null, FillWithGroupMode.None, MultipleMode.Count, MultipleCount: "1", PlayoutDuration: null, TailMode.None, DiscardToFillAttempts: null, CustomTitle: null, GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null, TailFillerId: null, FallbackFillerId: null, WatermarkIds: [], GraphicsElementIds: [], PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null, SubtitleMode: null); }