Files
ersatztv/ErsatzTV.Tests/Application/ProgramSchedules/ReplaceProgramScheduleItemsReconcileTests.cs
T
timothyandClaude Opus 4.8 8090e10408 fix(api): #265 — If-Match evaluates per RFC 7232 (valid-but-non-matching → 412, not 400)
The shared optimistic-concurrency parser (ConcurrencyHeaders.ParseIfMatch) classified any
non-canonical/weak/list If-Match value as Malformed → 400. Per RFC 7232 §3.1 a syntactically
-valid entity-tag that simply doesn't strong-match must be 412; 400 is only for a genuine
grammar violation.

- Rewrite ParseIfMatch as a real RFC 7232 entity-tag/list parser: walks the comma-separated
  1#entity-tag list, validates each [W/]DQUOTE *etagc DQUOTE member, and collects the strong
  members whose opaque text is our canonical decimal. Weak / empty / non-canonical /
  out-of-range tags are valid but contribute no version (→ empty set → 412); genuine grammar
  violations (unquoted, SP-in-tag, unterminated, garbage) → 400.
- Reshape IfMatchCondition.ExpectedVersion : Option<int> → ExpectedVersions : Option<Seq<int>>
  and VersionedAggregateExtensions.CheckVersion → set membership (any strong match proceeds;
  empty set always 412). Threads through 10 replace/update commands + handlers + request
  mappers + 9 controllers.
- No wire-contract change (400 + 412 already declared on every PUT; the field is header-derived
  and internal — no DTO/route/response-type/OpenAPI change).
- Tests: ConcurrencyHeadersTests rewritten for the new classification (lists, weak, empty,
  non-canonical → Version/empty-set; grammar violations → Malformed) + new
  VersionedAggregateExtensionsTests for CheckVersion membership/empty-set/force-write.
- Docs: api-conventions.md §7a rewritten; decisions.md entry appended.

Refs #253 #197
fixes #265

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:09:36 +02:00

619 lines
28 KiB
C#

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;
/// <summary>
/// Regression tests for #252: the ReplaceProgramScheduleItems write path used to delete every existing
/// item and re-insert with fresh ids, which cascade-deleted <see cref="PlayoutScheduleItemFillGroupIndex" />
/// (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.
/// </summary>
[TestFixture]
public class ReplaceProgramScheduleItemsReconcileTests
{
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 NoOp_Replace_Should_Preserve_Item_Ids_And_FillGroup_State()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var items = new List<ReplaceProgramScheduleItem>
{
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<int> originalIds;
int fillGroupItemId;
await using (TvContext ctx = _db.CreateContext())
{
List<ProgramScheduleItem> 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<int> 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<PlayoutScheduleItemFillGroupIndex>()
.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<ReplaceProgramScheduleItem>
{
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<int> originalIds;
int untouchedItemId;
await using (TvContext ctx = _db.CreateContext())
{
List<ProgramScheduleItem> 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<ReplaceProgramScheduleItem>
{
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<int> 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<PlayoutScheduleItemFillGroupIndex>()
.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<ReplaceProgramScheduleItem> { 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<ReplaceProgramScheduleItem> { 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<ReplaceProgramScheduleItem> { 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<ReplaceProgramScheduleItem>
{
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<ProgramScheduleItemDuration>();
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<ReplaceProgramScheduleItem>
{
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<ProgramScheduleItem> 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<ReplaceProgramScheduleItem>
{
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<ProgramScheduleItem> 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<PlayoutScheduleItemFillGroupIndex> groups = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
.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<ReplaceProgramScheduleItem>
{
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<ProgramScheduleItem> 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<ReplaceProgramScheduleItem>
{
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<ProgramScheduleItem> 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<PlayoutScheduleItemFillGroupIndex> groups = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
.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<ReplaceProgramScheduleItem>
{
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<ProgramScheduleItem> 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<ReplaceProgramScheduleItem> { 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<ProgramScheduleItem> 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<PlayoutScheduleItemFillGroupIndex>()
.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<ReplaceProgramScheduleItem> { MakeItem(0, PlayoutMode.One, "x", id: 999999) };
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<ReplaceProgramScheduleItem>
{
MakeItem(0, PlayoutMode.One, "a", id: idA),
MakeItem(1, PlayoutMode.One, "a-again", id: idA)
};
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<ReplaceProgramScheduleItem> { MakeItem(0, PlayoutMode.One, "x", id: 999999) };
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<ReplaceProgramScheduleItem>
{
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<ProgramScheduleItem> 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<ReplaceProgramScheduleItem>
{
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<ProgramScheduleItem> 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<ProgramScheduleItemMultiple>();
b.Index.ShouldBe(1);
PlayoutScheduleItemFillGroupIndex bState = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
.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<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<int> 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<int> 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);
}