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>
619 lines
28 KiB
C#
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);
|
|
}
|