Files
ersatztv/ErsatzTV.Tests/Application/Scheduling/ReplaceTemplateItemsHandlerConcurrencyTests.cs
T
timothyandClaude Opus 4.8 611924c0ee feat(#253 PR2): optimistic-concurrency contract for Template and DecoTemplate
Wire the frozen ETag/If-Match/412 recipe (Block reference implementation)
onto the Template and DecoTemplate aggregates:

- ReplaceTemplateItems / ReplaceDecoTemplateItems commands gain
  Option<int> ExpectedVersion; ToCommand() on the request DTOs threads it
  through from If-Match.
- Handlers introduce the version check as a standalone Either after
  validation (never via Apply), bump Version unconditionally before
  saving, and persist through SaveChangesWithConcurrencyGuard so a losing
  writer maps to 412 instead of 500. DecoTemplate's post-commit playout
  Reset enqueue now only runs after a successful save.
- TemplateViewModel / DecoTemplateViewModel carry Version (header-only,
  not echoed in the response body), populated in Mapper.
- TemplateController / DecoTemplateController: GET items emits a strong
  ETag of the root's version; PUT parses If-Match (400 on malformed),
  threads the expected version into the command, and returns the new
  ETag from the refreshed root on success. Both PUT actions now use the
  handler's returned item list directly instead of re-querying items.
- SPA: templates.ts / decoTemplates.ts gain getXItemsWithMeta and an
  If-Match-aware replaceX; TemplateEditor / DecoTemplateEditor hold the
  ETag in a ref, read items-with-meta first on load, and open a
  "changed elsewhere" ConfirmDialog on a 412 instead of navigating away.

Tests: new ReplaceTemplateItemsHandlerConcurrencyTests /
ReplaceDecoTemplateItemsHandlerConcurrencyTests mirror the Block
concurrency contract tests (stale/matching/absent If-Match, no-op bump,
racing-save 412, non-vacuous backstop). TemplateControllerTests /
DecoTemplateControllerTests gain ETag/If-Match/412 coverage.
TemplatesScreen.test.tsx / DecoTemplatesScreen.test.tsx gain a 412
conflict-dialog test mirroring BlocksScreen's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:36:35 +02:00

164 lines
6.3 KiB
C#

using ErsatzTV.Application;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
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.Scheduling;
/// <summary>
/// Contract tests for the #253 optimistic-concurrency mechanic on the Template aggregate,
/// mirroring <see cref="ReplaceBlockItemsHandlerConcurrencyTests" /> (the Block reference
/// implementation): the handler pre-check (stale If-Match → 412), the force-write path (no
/// If-Match), the unconditional Version bump on every save, 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 Template and the losing save
/// silently succeeds instead of mapping to a <see cref="PreconditionFailedError" />.
/// </summary>
[TestFixture]
public class ReplaceTemplateItemsHandlerConcurrencyTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private async Task SeedTemplateAsync(int version)
{
await using TvContext ctx = _db.CreateContext();
ctx.Blocks.Add(
new Block
{
Id = 10,
BlockGroupId = 1,
Name = "Morning",
Minutes = 30,
StopScheduling = BlockStopScheduling.AfterDurationEnd,
Items = new List<BlockItem>()
});
ctx.Templates.Add(
new Template
{
Id = 1,
TemplateGroupId = 1,
Name = "Weekday",
Version = version,
Items = new List<TemplateItem>()
});
await ctx.SaveChangesAsync();
}
private static ReplaceTemplateItems Command(Option<int> expectedVersion) =>
new(
1,
1,
"Weekday",
new List<ReplaceTemplateItem> { new(10, TimeSpan.Zero) },
expectedVersion);
private async Task<int> ReadVersionAsync()
{
await using TvContext ctx = _db.CreateContext();
return await ctx.Templates.Where(t => t.Id == 1).Select(t => t.Version).SingleAsync();
}
private static BaseError? LeftOrNull<T>(Either<BaseError, T> result) =>
result.Match<BaseError?>(Right: _ => null, Left: e => e);
[Test]
public async Task Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
{
await SeedTemplateAsync(version: 2);
var handler = new ReplaceTemplateItemsHandler(_db.Factory);
Either<BaseError, List<TemplateItemViewModel>> result =
await handler.Handle(Command(Some(1)), CancellationToken.None);
LeftOrNull(result).ShouldBeOfType<PreconditionFailedError>();
// The pre-check runs before any mutation: version unchanged, no items written.
(await ReadVersionAsync()).ShouldBe(2);
await using TvContext ctx = _db.CreateContext();
(await ctx.TemplateItems.CountAsync(i => i.TemplateId == 1)).ShouldBe(0);
}
[Test]
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
{
await SeedTemplateAsync(version: 2);
var handler = new ReplaceTemplateItemsHandler(_db.Factory);
Either<BaseError, List<TemplateItemViewModel>> 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 SeedTemplateAsync(version: 2);
var handler = new ReplaceTemplateItemsHandler(_db.Factory);
// None expected version = Phase-1 force-write regardless of the stored version.
Either<BaseError, List<TemplateItemViewModel>> result =
await handler.Handle(Command(None), CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersionAsync()).ShouldBe(3);
}
[Test]
public async Task Save_Should_Bump_Version_Even_When_Content_Is_Unchanged()
{
await SeedTemplateAsync(version: 5);
var handler = new ReplaceTemplateItemsHandler(_db.Factory);
// Same content twice: the unconditional bump (M1) must still rotate the version each time,
// otherwise a no-op PUT-back would not fire the token or 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 SeedTemplateAsync(version: 1);
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
// Template 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();
Template winner = await ctxWinner.Templates.SingleAsync(t => t.Id == 1);
Template loser = await ctxLoser.Templates.SingleAsync(t => t.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);
LeftOrNull(loserResult).ShouldBeOfType<PreconditionFailedError>();
// Non-vacuous: the winner's write stuck at exactly its +1; the loser did not overwrite it.
(await ReadVersionAsync()).ShouldBe(2);
}
}