Files
ersatztv/ErsatzTV.Tests/Application/Concurrency/RootWriterForceVersionTests.cs
T

239 lines
10 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Application.Concurrency;
/// <summary>
/// #269: once a versioned root's <c>Version</c> is an <c>IsConcurrencyToken</c> (#253), EF appends
/// <c>WHERE Version=@orig</c> to <b>every</b> UPDATE/DELETE of that row — so a non-If-Match root writer
/// (any of the 9 aggregate delete handlers; a settings edit like <see cref="UpdateProgramScheduleHandler" />;
/// or an item add/remove bumper such as <c>Add*ToPlaylist</c> / <c>Add|DeleteProgramScheduleItem</c>) that
/// saves via plain
/// <c>SaveChangesAsync</c> throws an unhandled <see cref="DbUpdateConcurrencyException" /> → 500 when a
/// replace-all editor bumps the row in the load→save window. Note the exposure is "any handler that
/// leaves a versioned root <c>Modified</c>/<c>Deleted</c>", NOT only <c>Version</c>-bumpers —
/// <see cref="ErasePlayoutHistoryHandler" /> writes Playout scalars <b>without</b> bumping and is
/// exposed too. These writers now save via
/// <see cref="ConcurrencyExtensions.SaveChangesForcingVersion" /> (Phase-1 force-write): the concurrent
/// bump is adopted and the write succeeds instead of 500-ing.
///
/// The handler tests are non-vacuous by construction — they reproduce the race THROUGH the handler by
/// handing it a context whose root is already tracked at the stale version (EF identity resolution keeps
/// the tracked scalar), then bumping the DB row from a second context. Revert any of the handlers
/// exercised below (a delete, a bump+update, and the non-bumping scalar-write erase) to plain
/// <c>SaveChangesAsync</c> and its test throws <see cref="DbUpdateConcurrencyException" /> instead of
/// returning success — see the explicit negative control at the bottom. The remaining routed handlers
/// (the other deletes and the 7 item add/remove bumpers) are structurally identical one-line swaps to
/// this same helper.
/// </summary>
[TestFixture]
public class RootWriterForceVersionTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
// A factory that always hands out a specific, already-open context so a handler's query resolves the
// pre-tracked (stale-version) root via EF's identity map instead of re-reading the bumped DB row.
private sealed class PreTrackedFactory(TvContext context) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() => context;
}
private async Task SeedBlock(int id, int version)
{
await using TvContext ctx = _db.CreateContext();
ctx.Blocks.Add(new Block
{
Id = id,
BlockGroupId = 1,
Name = "Morning",
Minutes = 30,
StopScheduling = BlockStopScheduling.AfterDurationEnd,
Version = version,
Items = new List<BlockItem>()
});
await ctx.SaveChangesAsync();
}
private async Task SeedProgramSchedule(int id, int version, string name = "Schedule")
{
await using TvContext ctx = _db.CreateContext();
ctx.ProgramSchedules.Add(new ProgramSchedule
{
Id = id,
Name = name,
Version = version,
Items = new List<ProgramScheduleItem>()
});
await ctx.SaveChangesAsync();
}
private async Task BumpVersion<T>(Func<TvContext, DbSet<T>> set, int id) where T : class
{
await using TvContext ctx = _db.CreateContext();
var entity = (IVersionedAggregate)(await set(ctx).FindAsync(id)
?? throw new InvalidOperationException("seed missing"));
entity.Version++;
await ctx.SaveChangesAsync();
}
[Test]
public async Task DeleteBlock_Should_Force_Delete_Past_A_Concurrent_Version_Bump()
{
await SeedBlock(1, version: 1);
// The handler's context has already loaded the block at version 1 (tracked).
TvContext handlerContext = _db.CreateContext();
_ = await handlerContext.Blocks.SingleAsync(b => b.Id == 1);
// A replace-all editor bumps the same row to version 2 in the DB.
await BumpVersion(c => c.Blocks, 1);
var handler = new DeleteBlockHandler(new PreTrackedFactory(handlerContext));
Option<BaseError> result = await handler.Handle(new DeleteBlock(1), CancellationToken.None);
result.IsNone.ShouldBeTrue();
await using TvContext verify = _db.CreateContext();
(await verify.Blocks.AnyAsync(b => b.Id == 1)).ShouldBeFalse();
}
[Test]
public async Task DeleteProgramSchedule_Should_Force_Delete_Past_A_Concurrent_Version_Bump()
{
await SeedProgramSchedule(1, version: 1);
TvContext handlerContext = _db.CreateContext();
_ = await handlerContext.ProgramSchedules.SingleAsync(p => p.Id == 1);
await BumpVersion(c => c.ProgramSchedules, 1);
var handler = new DeleteProgramScheduleHandler(new PreTrackedFactory(handlerContext));
Either<BaseError, Unit> result = await handler.Handle(new DeleteProgramSchedule(1), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext verify = _db.CreateContext();
(await verify.ProgramSchedules.AnyAsync(p => p.Id == 1)).ShouldBeFalse();
}
[Test]
public async Task ErasePlayoutHistory_Should_Force_Write_Its_Scalar_Edits_Past_A_Concurrent_Version_Bump()
{
// ErasePlayoutHistory modifies Playout ROOT scalars (Seed/Anchor/OnDemandCheckpoint) WITHOUT
// bumping Version, so it fell outside a "Version-bumper" sweep yet is still token-guarded and 500s
// on a concurrent bump (review of PR #302). It runs its save inside an explicit transaction with
// no try/catch, so this exercises force-write in that path too.
await using TvContext seed = _db.CreateContext();
seed.Playouts.Add(new Playout
{
Id = 1,
ChannelId = 1,
Version = 1,
ScheduleKind = PlayoutScheduleKind.Block,
Seed = 0,
Items = []
});
await seed.SaveChangesAsync();
TvContext handlerContext = _db.CreateContext();
_ = await handlerContext.Playouts.SingleAsync(p => p.Id == 1);
// A concurrent alt-schedule/template edit bumps Playout.Version to 2.
await BumpVersion(c => c.Playouts, 1);
var handler = new ErasePlayoutHistoryHandler(new PreTrackedFactory(handlerContext));
// Must not throw (would be a 500 on plain SaveChangesAsync); the erase force-writes.
await handler.Handle(new ErasePlayoutHistory(1), CancellationToken.None);
await using TvContext verify = _db.CreateContext();
(await verify.Playouts.AnyAsync(p => p.Id == 1)).ShouldBeTrue();
(await verify.Playouts.Where(p => p.Id == 1).Select(p => p.Version).SingleAsync()).ShouldBe(2);
}
[Test]
public async Task UpdateProgramSchedule_Should_Force_Write_Past_A_Concurrent_Version_Bump()
{
await SeedProgramSchedule(1, version: 1, name: "Before");
TvContext handlerContext = _db.CreateContext();
_ = await handlerContext.ProgramSchedules.SingleAsync(p => p.Id == 1);
// Concurrent bump to a distinct value so the adopt-stored-token path is exercised (not a lucky match).
await BumpVersion(c => c.ProgramSchedules, 1);
await BumpVersion(c => c.ProgramSchedules, 1); // DB version is now 3
ChannelWriter<IBackgroundServiceRequest> worker =
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
var handler = new UpdateProgramScheduleHandler(new PreTrackedFactory(handlerContext), worker);
// Name-only change → no playout refresh; the force-write must not 500 on the concurrent bump.
Either<BaseError, UpdateProgramScheduleResult> result = await handler.Handle(
new UpdateProgramSchedule(1, "After", false, false, false, false, default, null),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext verify = _db.CreateContext();
(await verify.ProgramSchedules.Where(p => p.Id == 1).Select(p => p.Name).SingleAsync())
.ShouldBe("After");
}
[Test]
public async Task SaveChangesForcingVersion_Should_Rethrow_When_The_Row_Was_Deleted_Concurrently()
{
await SeedBlock(1, version: 1);
await using TvContext ctxDelete = _db.CreateContext();
Block toDelete = await ctxDelete.Blocks.SingleAsync(b => b.Id == 1);
// A second writer deletes the row out from under us — a genuine conflict, not a token race.
await using (TvContext ctxOther = _db.CreateContext())
{
Block other = await ctxOther.Blocks.SingleAsync(b => b.Id == 1);
ctxOther.Blocks.Remove(other);
await ctxOther.SaveChangesAsync();
}
ctxDelete.Blocks.Remove(toDelete);
await Should.ThrowAsync<DbUpdateConcurrencyException>(
async () => await ctxDelete.SaveChangesForcingVersion(CancellationToken.None));
}
[Test]
public async Task Plain_SaveChangesAsync_Deleting_A_Bumped_Root_Throws_NegativeControl()
{
// Proves the exposure is real (and that SaveChangesForcingVersion is what the handlers rely on to
// avoid it): the exact same setup as DeleteBlock above, but a plain SaveChangesAsync throws.
await SeedBlock(1, version: 1);
await using TvContext ctxDelete = _db.CreateContext();
Block toDelete = await ctxDelete.Blocks.SingleAsync(b => b.Id == 1);
await BumpVersion(c => c.Blocks, 1); // DB version is now 2, ctxDelete still tracks version 1
ctxDelete.Blocks.Remove(toDelete);
await Should.ThrowAsync<DbUpdateConcurrencyException>(
async () => await ctxDelete.SaveChangesAsync());
}
}