Files
ersatztv/ErsatzTV.Tests/Application/MediaCollections/CollectionCustomOrderConcurrencyTests.cs
T
timothyandClaude Opus 4.8 8090e10408 fix(api): #265 — If-Match evaluates per RFC 7232 (valid-but-non-matching → 412, not 400)
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>
2026-07-12 23:09:36 +02:00

83 lines
2.8 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 Collection custom-order handler (#6): the pre-check
/// 412 (standalone Either after validation, not flattened to 422) and the unconditional Version bump.
/// </summary>
[TestFixture]
public class CollectionCustomOrderConcurrencyTests : MediaCollectionHandlerTestBase
{
private async Task SeedCollectionWithVersion(int id, int version, string name = "Collection")
{
await using TvContext context = Db.CreateContext();
context.Collections.Add(new Collection
{
Id = id,
Name = name,
Version = version,
MediaItems = [],
CollectionItems = []
});
await context.SaveChangesAsync();
}
private async Task<int> ReadVersion(int id)
{
await using TvContext context = Db.CreateContext();
return await context.Collections.Where(c => c.Id == id).Select(c => c.Version).SingleAsync();
}
private UpdateCollectionCustomOrderHandler MakeHandler()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([]);
return new UpdateCollectionCustomOrderHandler(Db.Factory, repo, Worker);
}
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 Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
{
await SeedCollectionWithVersion(1, version: 2);
Either<BaseError, Unit> result = await MakeHandler().Handle(
new UpdateCollectionCustomOrder(1, [], Some(new[] { 1 }.ToSeq())),
CancellationToken.None);
LeftOf(result).ShouldBeOfType<PreconditionFailedError>();
(await ReadVersion(1)).ShouldBe(2);
}
[Test]
public async Task Matching_If_Match_Should_Succeed_And_Bump()
{
await SeedCollectionWithVersion(1, version: 2);
Either<BaseError, Unit> result = await MakeHandler().Handle(
new UpdateCollectionCustomOrder(1, [], Some(new[] { 2 }.ToSeq())),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(3);
}
}