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);
}