Wire the frozen #253 ETag/If-Match/412 recipe onto ProgramSchedule / schedule-items, keeping the PR#258 positional in-place reconcile intact. Backend: - ReplaceProgramScheduleItems command gains Option<int> ExpectedVersion; ReplaceScheduleItemsRequest.ToCommand threads it. - Handler: standalone CheckVersion Either AFTER validation (so 412 isn't flattened to 422), unconditional Version++ before save, guarded save via SaveChangesWithConcurrencyGuard, and 412 propagated without running the post-save reload/enqueue. - ProgramScheduleViewModel + Mapper carry Version. - ScheduleController: GET /items emits ETag; PUT /items parses If-Match (malformed -> 400), threads ExpectedVersion, re-queries for the new ETag, and advertises 400/412. - Sibling config-writers (Add/Delete item, Update schedule) bump Version. Frontend: - schedules.ts: getScheduleItemsWithMeta + replaceScheduleItems(ifMatch) returning ResponseWithMeta. - SchedulesScreen: etagRef threaded through the #242 dirty-guard (set from load + every successful save); 412 opens a conflict ConfirmDialog whose Reload discards the draft and re-runs loadItems. Tests: handler concurrency suite (stale->412 no mutation + fill-group state untouched, match/absent success+bump, no-op still bumps, racing save->412); controller ETag/If-Match/412 cases; SchedulesScreen 412-conflict-dialog test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
9.6 KiB
C#
232 lines
9.6 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>
|
|
/// Contract tests for the #253 optimistic-concurrency mechanic on the ProgramSchedule / schedule-items
|
|
/// aggregate: the handler pre-check (stale If-Match → 412, before the positional reconcile runs — so the
|
|
/// item rows AND persisted fill-group/shuffle state are left untouched), the force-write path (no
|
|
/// If-Match), the unconditional Version bump on every save (including a no-op same-items PUT-back where
|
|
/// only child rows change), and the EF concurrency-token backstop that catches a writer that lost the
|
|
/// load→save race. The backstop test is non-vacuous by construction — remove the
|
|
/// <c>IsConcurrencyToken()</c> config on ProgramSchedule and the losing save silently succeeds instead
|
|
/// of mapping to a <see cref="PreconditionFailedError" />.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class ReplaceProgramScheduleItemsHandlerConcurrencyTests
|
|
{
|
|
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();
|
|
|
|
// Seeds a schedule (Id=1) with a single One/SearchQuery item (Id=1) and a persisted fill-group
|
|
// enumerator state pointing at that item, so the stale-If-Match test can prove the reconcile never ran.
|
|
private async Task SeedScheduleAsync(int version)
|
|
{
|
|
await using TvContext ctx = _db.CreateContext();
|
|
ctx.ProgramSchedules.Add(
|
|
new ProgramSchedule
|
|
{
|
|
Id = 1,
|
|
Name = "Concurrency",
|
|
Version = version,
|
|
Items = new List<ProgramScheduleItem>
|
|
{
|
|
new ProgramScheduleItemOne
|
|
{
|
|
Id = 1,
|
|
Index = 0,
|
|
CollectionType = CollectionType.SearchQuery,
|
|
SearchTitle = "a",
|
|
SearchQuery = "a",
|
|
PlaybackOrder = PlaybackOrder.Shuffle,
|
|
GuideMode = GuideMode.Normal
|
|
}
|
|
},
|
|
Playouts = [],
|
|
ProgramScheduleAlternates = []
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
|
|
ctx.Add(new PlayoutScheduleItemFillGroupIndex
|
|
{
|
|
PlayoutId = 1,
|
|
ProgramScheduleItemId = 1,
|
|
EnumeratorState = new CollectionEnumeratorState { Seed = 12345, Index = 7 }
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
private static ReplaceProgramScheduleItems Command(
|
|
Option<int> expectedVersion,
|
|
List<ReplaceProgramScheduleItem>? items = null) =>
|
|
new(1, items ?? [MakeItem(0, PlayoutMode.One, "a")], expectedVersion);
|
|
|
|
private async Task<int> ReadVersionAsync()
|
|
{
|
|
await using TvContext ctx = _db.CreateContext();
|
|
return await ctx.ProgramSchedules.Where(s => s.Id == 1).Select(s => s.Version).SingleAsync();
|
|
}
|
|
|
|
private static BaseError? LeftOrNull(Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result) =>
|
|
result.Match<BaseError?>(Right: _ => null, Left: e => e);
|
|
|
|
[Test]
|
|
public async Task Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
|
|
{
|
|
await SeedScheduleAsync(version: 2);
|
|
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
|
|
|
// An empty item list WOULD delete the existing item (and cascade its fill-group state) if the
|
|
// reconcile ran. A stale If-Match must reject before that, leaving everything untouched.
|
|
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
|
await handler.Handle(Command(Some(1), []), CancellationToken.None);
|
|
|
|
LeftOrNull(result).ShouldBeOfType<PreconditionFailedError>();
|
|
|
|
(await ReadVersionAsync()).ShouldBe(2);
|
|
await using TvContext ctx = _db.CreateContext();
|
|
(await ctx.ProgramScheduleItems.CountAsync(i => i.ProgramScheduleId == 1)).ShouldBe(1);
|
|
|
|
// The reconcile never ran: the fill-group enumerator state is exactly as seeded.
|
|
PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
|
|
.Include(x => x.EnumeratorState)
|
|
.SingleAsync();
|
|
fillGroup.ProgramScheduleItemId.ShouldBe(1);
|
|
fillGroup.EnumeratorState.Seed.ShouldBe(12345);
|
|
fillGroup.EnumeratorState.Index.ShouldBe(7);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
|
|
{
|
|
await SeedScheduleAsync(version: 2);
|
|
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
|
|
|
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
|
await handler.Handle(Command(Some(2)), CancellationToken.None);
|
|
|
|
result.IsRight.ShouldBeTrue();
|
|
(await ReadVersionAsync()).ShouldBe(3);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Absent_If_Match_Should_Force_Write_And_Bump_Version()
|
|
{
|
|
await SeedScheduleAsync(version: 2);
|
|
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
|
|
|
// None expected version = Phase-1 force-write regardless of the stored version.
|
|
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
|
await handler.Handle(Command(None), CancellationToken.None);
|
|
|
|
result.IsRight.ShouldBeTrue();
|
|
(await ReadVersionAsync()).ShouldBe(3);
|
|
}
|
|
|
|
[Test]
|
|
public async Task NoOp_Save_Should_Bump_Version_Even_With_Only_Child_Changes()
|
|
{
|
|
await SeedScheduleAsync(version: 5);
|
|
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
|
|
|
// Same content twice: this handler saves with only CHILD changes and no root-scalar change, so the
|
|
// unconditional bump (M1) must still rotate the version each time, otherwise a no-op PUT-back would
|
|
// neither fire the token nor rotate other clients' ETags.
|
|
(await handler.Handle(Command(Some(5)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
|
(await ReadVersionAsync()).ShouldBe(6);
|
|
(await handler.Handle(Command(Some(6)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
|
(await ReadVersionAsync()).ShouldBe(7);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Racing_Save_Should_Map_Concurrency_Failure_To_412()
|
|
{
|
|
await SeedScheduleAsync(version: 1);
|
|
|
|
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
|
|
// ProgramSchedule makes the second UPDATE key on the original version; it matches zero rows and
|
|
// throws DbUpdateConcurrencyException, which the shared save helper maps to a PreconditionFailedError.
|
|
await using TvContext ctxWinner = _db.CreateContext();
|
|
await using TvContext ctxLoser = _db.CreateContext();
|
|
|
|
ProgramSchedule winner = await ctxWinner.ProgramSchedules.SingleAsync(s => s.Id == 1);
|
|
ProgramSchedule loser = await ctxLoser.ProgramSchedules.SingleAsync(s => s.Id == 1);
|
|
|
|
winner.Version++;
|
|
Either<BaseError, Unit> winnerResult = await ctxWinner.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
|
winnerResult.IsRight.ShouldBeTrue();
|
|
|
|
loser.Version++;
|
|
Either<BaseError, Unit> loserResult = await ctxLoser.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
|
|
|
loserResult.Match<BaseError?>(Right: _ => null, Left: e => e).ShouldBeOfType<PreconditionFailedError>();
|
|
|
|
// Non-vacuous: the winner's write stuck at exactly its +1; the loser did not overwrite it.
|
|
(await ReadVersionAsync()).ShouldBe(2);
|
|
}
|
|
|
|
private static ReplaceProgramScheduleItem MakeItem(int index, PlayoutMode mode, string searchQuery) =>
|
|
new(
|
|
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);
|
|
}
|