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>
108 lines
4.0 KiB
C#
108 lines
4.0 KiB
C#
using System.Threading.Channels;
|
|
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.MediaCollections;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using Unit = LanguageExt.Unit;
|
|
using static LanguageExt.Prelude;
|
|
|
|
namespace ErsatzTV.Tests.Application.MediaCollections;
|
|
|
|
/// <summary>
|
|
/// #253 optimistic-concurrency contract on the MultiCollection replace handler (#9): pre-check 412
|
|
/// (standalone Either, not flattened to 422), the unconditional Version bump, and the M2 rework of
|
|
/// the old <c>SaveChangesAsync() > 0</c> gate — a name-only change still saves (and bumps) but must
|
|
/// NOT rebuild playouts, since the version bump rides the first (name) save while the item save stays
|
|
/// gated on real item changes.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class MultiCollectionConcurrencyTests : MediaCollectionHandlerTestBase
|
|
{
|
|
private static UpdateMultiCollection Update(int id, string name, Option<int> expectedVersion) =>
|
|
new(id, name, [], expectedVersion.Map(v => new[] { v }.ToSeq()));
|
|
|
|
private async Task SeedMultiCollection(int id, int version, string name = "Multi")
|
|
{
|
|
await using TvContext context = Db.CreateContext();
|
|
context.MultiCollections.Add(new MultiCollection
|
|
{
|
|
Id = id,
|
|
Name = name,
|
|
Version = version,
|
|
MultiCollectionItems = [],
|
|
MultiCollectionSmartItems = []
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task<int> ReadVersion(int id)
|
|
{
|
|
await using TvContext context = Db.CreateContext();
|
|
return await context.MultiCollections.Where(c => c.Id == id).Select(c => c.Version).SingleAsync();
|
|
}
|
|
|
|
private UpdateMultiCollectionHandler MakeHandler(IMediaCollectionRepository repo) =>
|
|
new(Db.Factory, repo, Worker, SearchTargets);
|
|
|
|
private static IMediaCollectionRepository EmptyRepo()
|
|
{
|
|
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
|
repo.PlayoutIdsUsingMultiCollection(Arg.Any<int>()).Returns([]);
|
|
return repo;
|
|
}
|
|
|
|
private static BaseError LeftOf(Either<BaseError, Unit> either) =>
|
|
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
|
|
|
[Test]
|
|
public async Task Update_Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
|
|
{
|
|
await SeedMultiCollection(1, version: 2);
|
|
|
|
Either<BaseError, Unit> result =
|
|
await MakeHandler(EmptyRepo()).Handle(Update(1, "Renamed", Some(1)), CancellationToken.None);
|
|
|
|
LeftOf(result).ShouldBeOfType<PreconditionFailedError>();
|
|
(await ReadVersion(1)).ShouldBe(2);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Matching_If_Match_Should_Succeed_And_Bump()
|
|
{
|
|
await SeedMultiCollection(1, version: 2);
|
|
|
|
Either<BaseError, Unit> result =
|
|
await MakeHandler(EmptyRepo()).Handle(Update(1, "Renamed", Some(2)), CancellationToken.None);
|
|
|
|
result.IsRight.ShouldBeTrue();
|
|
(await ReadVersion(1)).ShouldBe(3);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Name_Only_Change_Should_Bump_But_Not_Rebuild_Playouts()
|
|
{
|
|
await SeedMultiCollection(1, version: 1, name: "Before");
|
|
IMediaCollectionRepository repo = EmptyRepo();
|
|
|
|
Either<BaseError, Unit> result =
|
|
await MakeHandler(repo).Handle(Update(1, "After", None), CancellationToken.None);
|
|
|
|
result.IsRight.ShouldBeTrue();
|
|
(await ReadVersion(1)).ShouldBe(2);
|
|
|
|
// M2: the version bump rides the first (name) save, so the second (items) save writes nothing →
|
|
// no rebuild is enqueued and the search index is not signalled for a name-only edit.
|
|
SearchTargets.DidNotReceive().SearchTargetsChanged();
|
|
await repo.DidNotReceive().PlayoutIdsUsingMultiCollection(Arg.Any<int>());
|
|
}
|
|
}
|