The shared optimistic-concurrency parser (ConcurrencyHeaders.ParseIfMatch) classified any non-canonical/weak/list If-Match value as Malformed → 400. Per RFC 7232 §3.1 a syntactically -valid entity-tag that simply doesn't strong-match must be 412; 400 is only for a genuine grammar violation. - Rewrite ParseIfMatch as a real RFC 7232 entity-tag/list parser: walks the comma-separated 1#entity-tag list, validates each [W/]DQUOTE *etagc DQUOTE member, and collects the strong members whose opaque text is our canonical decimal. Weak / empty / non-canonical / out-of-range tags are valid but contribute no version (→ empty set → 412); genuine grammar violations (unquoted, SP-in-tag, unterminated, garbage) → 400. - Reshape IfMatchCondition.ExpectedVersion : Option<int> → ExpectedVersions : Option<Seq<int>> and VersionedAggregateExtensions.CheckVersion → set membership (any strong match proceeds; empty set always 412). Threads through 10 replace/update commands + handlers + request mappers + 9 controllers. - No wire-contract change (400 + 412 already declared on every PUT; the field is header-derived and internal — no DTO/route/response-type/OpenAPI change). - Tests: ConcurrencyHeadersTests rewritten for the new classification (lists, weak, empty, non-canonical → Version/empty-set; grammar violations → Malformed) + new VersionedAggregateExtensionsTests for CheckVersion membership/empty-set/force-write. - Docs: api-conventions.md §7a rewritten; decisions.md entry appended. Refs #253 #197 fixes #265 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
6.1 KiB
C#
158 lines
6.1 KiB
C#
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.Scheduling;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
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 Block reference aggregate:
|
|
/// 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 Block and the losing save silently succeeds instead of
|
|
/// mapping to a <see cref="PreconditionFailedError" />.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class ReplaceBlockItemsHandlerConcurrencyTests
|
|
{
|
|
private InMemoryTvContext _db = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
private async Task SeedBlockAsync(int version)
|
|
{
|
|
await using TvContext ctx = _db.CreateContext();
|
|
ctx.Blocks.Add(
|
|
new Block
|
|
{
|
|
Id = 1,
|
|
BlockGroupId = 1,
|
|
Name = "Morning",
|
|
Minutes = 30,
|
|
StopScheduling = BlockStopScheduling.AfterDurationEnd,
|
|
Version = version,
|
|
Items = new List<BlockItem>()
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
private static ReplaceBlockItems Command(Option<int> expectedVersion) =>
|
|
new(
|
|
1,
|
|
1,
|
|
"Morning",
|
|
30,
|
|
BlockStopScheduling.AfterDurationEnd,
|
|
new List<ReplaceBlockItem>
|
|
{
|
|
new(0, CollectionType.SearchQuery, null, null, null, null, "News", "news", PlaybackOrder.Shuffle,
|
|
IncludeInProgramGuide: true, DisableWatermarks: false, [], [])
|
|
},
|
|
expectedVersion.Map(v => new[] { v }.ToSeq()));
|
|
|
|
private async Task<int> ReadVersionAsync()
|
|
{
|
|
await using TvContext ctx = _db.CreateContext();
|
|
return await ctx.Blocks.Where(b => b.Id == 1).Select(b => b.Version).SingleAsync();
|
|
}
|
|
|
|
private static BaseError? LeftOrNull(Either<BaseError, Unit> result) =>
|
|
result.Match<BaseError?>(Right: _ => null, Left: e => e);
|
|
|
|
[Test]
|
|
public async Task Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
|
|
{
|
|
await SeedBlockAsync(version: 2);
|
|
var handler = new ReplaceBlockItemsHandler(_db.Factory);
|
|
|
|
Either<BaseError, Unit> 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.BlockItems.CountAsync(i => i.BlockId == 1)).ShouldBe(0);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
|
|
{
|
|
await SeedBlockAsync(version: 2);
|
|
var handler = new ReplaceBlockItemsHandler(_db.Factory);
|
|
|
|
Either<BaseError, Unit> 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 SeedBlockAsync(version: 2);
|
|
var handler = new ReplaceBlockItemsHandler(_db.Factory);
|
|
|
|
// None expected version = Phase-1 force-write regardless of the stored version.
|
|
Either<BaseError, Unit> 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 SeedBlockAsync(version: 5);
|
|
var handler = new ReplaceBlockItemsHandler(_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 SeedBlockAsync(version: 1);
|
|
|
|
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
|
|
// Block 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();
|
|
|
|
Block winner = await ctxWinner.Blocks.SingleAsync(b => b.Id == 1);
|
|
Block loser = await ctxLoser.Blocks.SingleAsync(b => b.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);
|
|
}
|
|
}
|