239 lines
10 KiB
C#
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());
|
|
}
|
|
}
|