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; /// /// 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 /// IsConcurrencyToken() config on ProgramSchedule and the losing save silently succeeds instead /// of mapping to a . /// [TestFixture] public class ReplaceProgramScheduleItemsHandlerConcurrencyTests { 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(); // 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 { 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 expectedVersion, List? items = null) => new(1, items ?? [MakeItem(0, PlayoutMode.One, "a")], expectedVersion.Map(v => new[] { v }.ToSeq())); private async Task 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> result) => result.Match(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> result = await handler.Handle(Command(Some(1), []), CancellationToken.None); LeftOrNull(result).ShouldBeOfType(); (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() .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> 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> 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 winnerResult = await ctxWinner.SaveChangesWithConcurrencyGuard(CancellationToken.None); winnerResult.IsRight.ShouldBeTrue(); loser.Version++; Either loserResult = await ctxLoser.SaveChangesWithConcurrencyGuard(CancellationToken.None); loserResult.Match(Right: _ => null, Left: e => e).ShouldBeOfType(); // 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( null, 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); }