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>
This commit is contained in:
2026-07-11 18:36:35 +02:00
co-authored by Claude Opus 4.8
parent 2de091ea4f
commit 611924c0ee
22 changed files with 926 additions and 120 deletions
@@ -6,5 +6,6 @@ public record ReplaceDecoTemplateItems(
int DecoTemplateId,
int DecoTemplateGroupId,
string Name,
List<ReplaceDecoTemplateItem> Items)
List<ReplaceDecoTemplateItem> Items,
Option<int> ExpectedVersion = default)
: IRequest<Either<BaseError, List<DecoTemplateItemViewModel>>>;
@@ -20,10 +20,19 @@ public class ReplaceDecoTemplateItemsHandler(
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, DecoTemplate> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
Either<BaseError, DecoTemplate> validated = LanguageExtensions.ToEither(validation)
.Bind(decoTemplate => decoTemplate.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: decoTemplate => Persist(dbContext, request, decoTemplate, cancellationToken),
Left: error => Task.FromResult<Either<BaseError, List<DecoTemplateItemViewModel>>>(error));
}
private async Task<List<DecoTemplateItemViewModel>> Persist(
private async Task<Either<BaseError, List<DecoTemplateItemViewModel>>> Persist(
TvContext dbContext,
ReplaceDecoTemplateItems request,
DecoTemplate decoTemplate,
@@ -36,33 +45,49 @@ public class ReplaceDecoTemplateItemsHandler(
decoTemplate.Items = request.Items.Map(i => BuildItem(decoTemplate, i)).ToList();
await dbContext.SaveChangesAsync(cancellationToken);
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
decoTemplate.Version++;
// Deco/break/default-filler content is only (re)applied during a Reset build (a Continue keeps the
// frozen DecoDefault filler items), and BlockKey change-detection has no deco dimension, so nothing
// self-heals a deco-template edit — the editor returned 200 but built filler stayed stale until a
// manual Reset (#251). Enqueue a Reset for every playout that references this deco template. This
// whole post-commit invalidation runs with CancellationToken.None (audit #22 policy): once the edit
// is committed, a late request cancellation must not be able to abort the affected-playout query OR
// the enqueue and leave content stale.
List<int> playoutIds = await dbContext.PlayoutTemplates
.Where(pt => pt.DecoTemplateId == decoTemplate.Id)
.Select(pt => pt.PlayoutId)
.Distinct()
.ToListAsync(CancellationToken.None);
// Save through the guard so an EF concurrency failure (a racing writer won between our load
// and save) maps to 412 rather than surfacing as a 500.
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
return await saved.Match(
Right: async _ =>
{
// Deco/break/default-filler content is only (re)applied during a Reset build (a Continue keeps
// the frozen DecoDefault filler items), and BlockKey change-detection has no deco dimension, so
// nothing self-heals a deco-template edit — the editor returned 200 but built filler stayed
// stale until a manual Reset (#251). Enqueue a Reset for every playout that references this
// deco template. This whole post-commit invalidation runs with CancellationToken.None (audit
// #22 policy): once the edit is committed, a late request cancellation must not be able to
// abort the affected-playout query OR the enqueue and leave content stale. Only runs after a
// successful save (issue #253) — a 412/422 must not enqueue a Reset for content that was never
// persisted.
List<int> playoutIds = await dbContext.PlayoutTemplates
.Where(pt => pt.DecoTemplateId == decoTemplate.Id)
.Select(pt => pt.PlayoutId)
.Distinct()
.ToListAsync(CancellationToken.None);
foreach (int playoutId in playoutIds)
{
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset), CancellationToken.None);
}
foreach (int playoutId in playoutIds)
{
await channel.WriteAsync(
new BuildPlayout(playoutId, PlayoutBuildMode.Reset),
CancellationToken.None);
}
await dbContext.Entry(decoTemplate)
.Collection(t => t.Items)
.Query()
.Include(i => i.Deco)
.LoadAsync(cancellationToken);
await dbContext.Entry(decoTemplate)
.Collection(t => t.Items)
.Query()
.Include(i => i.Deco)
.LoadAsync(cancellationToken);
return decoTemplate.Items.Map(Mapper.ProjectToViewModel).ToList();
return Right<BaseError, List<DecoTemplateItemViewModel>>(
decoTemplate.Items.Map(Mapper.ProjectToViewModel).ToList());
},
Left: error => Task.FromResult(Left<BaseError, List<DecoTemplateItemViewModel>>(error)));
}
private static DecoTemplateItem BuildItem(DecoTemplate decoTemplate, ReplaceDecoTemplateItem item) =>
@@ -2,5 +2,10 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
public record ReplaceTemplateItems(int TemplateGroupId, int TemplateId, string Name, List<ReplaceTemplateItem> Items)
public record ReplaceTemplateItems(
int TemplateGroupId,
int TemplateId,
string Name,
List<ReplaceTemplateItem> Items,
Option<int> ExpectedVersion = default)
: IRequest<Either<BaseError, List<TemplateItemViewModel>>>;
@@ -15,10 +15,19 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Template> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
Either<BaseError, Template> validated = LanguageExtensions.ToEither(validation)
.Bind(template => template.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: template => Persist(dbContext, request, template, cancellationToken),
Left: error => Task.FromResult<Either<BaseError, List<TemplateItemViewModel>>>(error));
}
private static async Task<List<TemplateItemViewModel>> Persist(
private static async Task<Either<BaseError, List<TemplateItemViewModel>>> Persist(
TvContext dbContext,
ReplaceTemplateItems request,
Template template,
@@ -30,7 +39,10 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
dbContext.RemoveRange(template.Items);
template.Items = request.Items.Map(i => BuildItem(template, i)).ToList();
await dbContext.SaveChangesAsync(cancellationToken);
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
template.Version++;
// TODO: refresh any playouts that use this schedule
// foreach (Playout playout in programSchedule.Playouts)
@@ -38,13 +50,22 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
// }
await dbContext.Entry(template)
.Collection(t => t.Items)
.Query()
.Include(i => i.Block)
.LoadAsync(cancellationToken);
// Save through the guard so an EF concurrency failure (a racing writer won between our load
// and save) maps to 412 rather than surfacing as a 500.
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
return await saved.Match(
Right: async _ =>
{
await dbContext.Entry(template)
.Collection(t => t.Items)
.Query()
.Include(i => i.Block)
.LoadAsync(cancellationToken);
return template.Items.Map(Mapper.ProjectToViewModel).ToList();
return Right<BaseError, List<TemplateItemViewModel>>(
template.Items.Map(Mapper.ProjectToViewModel).ToList());
},
Left: error => Task.FromResult(Left<BaseError, List<TemplateItemViewModel>>(error)));
}
private static TemplateItem BuildItem(Template template, ReplaceTemplateItem item) =>
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Scheduling;
public record DecoTemplateViewModel(int Id, int DecoTemplateGroupId, string GroupName, string Name);
public record DecoTemplateViewModel(int Id, int DecoTemplateGroupId, string GroupName, string Name, int Version);
+3 -2
View File
@@ -65,7 +65,7 @@ internal static class Mapper
new(templateGroup.Id, templateGroup.Name, templateGroup.Templates.Count);
internal static TemplateViewModel ProjectToViewModel(Template template) =>
new(template.Id, template.TemplateGroupId, template.TemplateGroup.Name, template.Name);
new(template.Id, template.TemplateGroupId, template.TemplateGroup.Name, template.Name, template.Version);
internal static TemplateItemViewModel ProjectToViewModel(TemplateItem templateItem)
{
@@ -168,7 +168,8 @@ internal static class Mapper
decoTemplate.Id,
decoTemplate.DecoTemplateGroupId,
decoTemplate.DecoTemplateGroup.Name,
decoTemplate.Name);
decoTemplate.Name,
decoTemplate.Version);
}
internal static DecoTemplateItemViewModel ProjectToViewModel(DecoTemplateItem decoTemplateItem)
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Scheduling;
public record TemplateViewModel(int Id, int TemplateGroupId, string GroupName, string Name);
public record TemplateViewModel(int Id, int TemplateGroupId, string GroupName, string Name, int Version);
@@ -0,0 +1,164 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
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 DecoTemplate 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 DecoTemplate and the losing save
/// silently succeeds instead of mapping to a <see cref="PreconditionFailedError" />.
/// </summary>
[TestFixture]
public class ReplaceDecoTemplateItemsHandlerConcurrencyTests
{
private InMemoryTvContext _db = null!;
private Channel<IBackgroundServiceRequest> _channel = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_channel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private async Task SeedDecoTemplateAsync(int version)
{
await using TvContext ctx = _db.CreateContext();
ctx.Decos.Add(new Deco { Id = 10, DecoGroupId = 1, Name = "D" });
ctx.DecoTemplates.Add(
new DecoTemplate
{
Id = 1,
DecoTemplateGroupId = 1,
Name = "Weekday",
Version = version,
Items = new List<DecoTemplateItem>()
});
await ctx.SaveChangesAsync();
}
private ReplaceDecoTemplateItemsHandler CreateHandler() => new(_db.Factory, _channel.Writer);
private static ReplaceDecoTemplateItems Command(Option<int> expectedVersion) =>
new(
DecoTemplateId: 1,
DecoTemplateGroupId: 1,
Name: "Weekday",
Items: [new ReplaceDecoTemplateItem(DecoId: 10, StartTime: TimeSpan.Zero, EndTime: TimeSpan.FromHours(1))],
ExpectedVersion: expectedVersion);
private async Task<int> ReadVersionAsync()
{
await using TvContext ctx = _db.CreateContext();
return await ctx.DecoTemplates.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 SeedDecoTemplateAsync(version: 2);
ReplaceDecoTemplateItemsHandler handler = CreateHandler();
Either<BaseError, List<DecoTemplateItemViewModel>> 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.DecoTemplateItems.CountAsync(i => i.DecoTemplateId == 1)).ShouldBe(0);
}
[Test]
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
{
await SeedDecoTemplateAsync(version: 2);
ReplaceDecoTemplateItemsHandler handler = CreateHandler();
Either<BaseError, List<DecoTemplateItemViewModel>> 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 SeedDecoTemplateAsync(version: 2);
ReplaceDecoTemplateItemsHandler handler = CreateHandler();
// None expected version = Phase-1 force-write regardless of the stored version.
Either<BaseError, List<DecoTemplateItemViewModel>> 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 SeedDecoTemplateAsync(version: 5);
ReplaceDecoTemplateItemsHandler handler = CreateHandler();
// 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 SeedDecoTemplateAsync(version: 1);
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
// DecoTemplate 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();
DecoTemplate winner = await ctxWinner.DecoTemplates.SingleAsync(t => t.Id == 1);
DecoTemplate loser = await ctxLoser.DecoTemplates.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);
}
}
@@ -0,0 +1,163 @@
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);
}
}
@@ -4,8 +4,10 @@ using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
@@ -25,7 +27,12 @@ public class DecoTemplateControllerTests
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new DecoTemplateController(_mediator);
_controller = new DecoTemplateController(_mediator)
{
// Provide a real HttpContext so the ETag/If-Match concurrency headers (#253) can be
// read from Request and written to Response.
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
}
[Test]
@@ -259,12 +266,8 @@ public class DecoTemplateControllerTests
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning")));
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(new List<DecoTemplateItemViewModel>
{
MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7))
});
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>(
[MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7))]));
IActionResult result = await _controller.Replace(
4,
@@ -318,11 +321,96 @@ public class DecoTemplateControllerTests
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>());
}
private static DecoTemplateViewModel MakeDecoTemplate(int id, int groupId, string name) =>
new(id, groupId, "Group", name);
[Test]
public async Task GetItems_Should_Set_ETag_From_DecoTemplate_Version()
{
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 2, "Morning", version: 9)));
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(new List<DecoTemplateItemViewModel>());
await _controller.GetItems(4, CancellationToken.None);
_controller.Response.Headers.ETag.ToString().ShouldBe("\"9\"");
}
[Test]
public async Task Replace_Should_Return_400_On_Malformed_If_Match()
{
_controller.Request.Headers.IfMatch = "not-an-etag";
IActionResult result = await _controller.Replace(
4,
new ReplaceDecoTemplateRequest("Morning", []),
CancellationToken.None);
result.ShouldBeOfType<BadRequestObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Replace_Should_Thread_If_Match_Version_Into_Command()
{
_controller.Request.Headers.IfMatch = "\"3\"";
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning", version: 4)));
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
IActionResult result = await _controller.Replace(
4,
new ReplaceDecoTemplateRequest("Morning", []),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
// On success the response carries the refreshed deco template's ETag.
_controller.Response.Headers.ETag.ToString().ShouldBe("\"4\"");
await _mediator.Received(1).Send(
Arg.Is<ReplaceDecoTemplateItems>(c => c.ExpectedVersion == Option<int>.Some(3)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Replace_Without_If_Match_Should_Force_Write()
{
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning")));
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
await _controller.Replace(
4,
new ReplaceDecoTemplateRequest("Morning", []),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<ReplaceDecoTemplateItems>(c => c.ExpectedVersion == Option<int>.None),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Replace_Should_Return_412_On_Precondition_Failed()
{
_controller.Request.Headers.IfMatch = "\"2\"";
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning", version: 5)));
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, List<DecoTemplateItemViewModel>>(new PreconditionFailedError("stale")));
IActionResult result = await _controller.Replace(
4,
new ReplaceDecoTemplateRequest("Morning", []),
CancellationToken.None);
var objectResult = result.ShouldBeOfType<ObjectResult>();
objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
}
private static DecoTemplateViewModel MakeDecoTemplate(int id, int groupId, string name, int version = 1) =>
new(id, groupId, "Group", name, version);
private static DecoTemplateItemViewModel MakeItem(int decoId, string decoName, TimeSpan startTime, TimeSpan endTime)
{
@@ -1108,7 +1108,7 @@ public class PlayoutControllerTests
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
.Returns([MakeTemplateViewModel(7), MakeTemplateViewModel(8)]);
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<DecoTemplateViewModel>.Some(new DecoTemplateViewModel(5, 1, "G", "DT")));
.Returns(Option<DecoTemplateViewModel>.Some(new DecoTemplateViewModel(5, 1, "G", "DT", 0)));
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
@@ -1250,13 +1250,13 @@ public class PlayoutControllerTests
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict);
private static TemplateViewModel MakeTemplateViewModel(int id) =>
new(id, 1, "Group", $"Template {id}");
new(id, 1, "Group", $"Template {id}", 0);
private static PlayoutTemplateViewModel MakeTemplateVm(int id, int index, int templateId, int? decoTemplateId) =>
new(
id,
new TemplateViewModel(templateId, 1, "Group", $"Template {templateId}"),
decoTemplateId is { } dtId ? new DecoTemplateViewModel(dtId, 1, "DGroup", $"Deco {dtId}") : null,
new TemplateViewModel(templateId, 1, "Group", $"Template {templateId}", 0),
decoTemplateId is { } dtId ? new DecoTemplateViewModel(dtId, 1, "DGroup", $"Deco {dtId}", 0) : null,
index,
[],
[],
@@ -4,8 +4,10 @@ using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
@@ -25,7 +27,12 @@ public class TemplateControllerTests
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new TemplateController(_mediator);
_controller = new TemplateController(_mediator)
{
// Provide a real HttpContext so the ETag/If-Match concurrency headers (#253) can be
// read from Request and written to Response.
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
}
[Test]
@@ -248,12 +255,8 @@ public class TemplateControllerTests
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning")));
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(new List<TemplateItemViewModel>
{
MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60)
});
.Returns(Right<BaseError, List<TemplateItemViewModel>>(
[MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60)]));
IActionResult result = await _controller.Replace(
4,
@@ -305,7 +308,92 @@ public class TemplateControllerTests
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItems_Should_Set_ETag_From_Template_Version()
{
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 2, "Morning", version: 9)));
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(new List<TemplateItemViewModel>());
await _controller.GetItems(4, CancellationToken.None);
_controller.Response.Headers.ETag.ToString().ShouldBe("\"9\"");
}
[Test]
public async Task Replace_Should_Return_400_On_Malformed_If_Match()
{
_controller.Request.Headers.IfMatch = "not-an-etag";
IActionResult result = await _controller.Replace(
4,
new ReplaceTemplateRequest("Morning", []),
CancellationToken.None);
result.ShouldBeOfType<BadRequestObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Replace_Should_Thread_If_Match_Version_Into_Command()
{
_controller.Request.Headers.IfMatch = "\"3\"";
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning", version: 4)));
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
IActionResult result = await _controller.Replace(
4,
new ReplaceTemplateRequest("Morning", []),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
// On success the response carries the refreshed template's ETag.
_controller.Response.Headers.ETag.ToString().ShouldBe("\"4\"");
await _mediator.Received(1).Send(
Arg.Is<ReplaceTemplateItems>(c => c.ExpectedVersion == Option<int>.Some(3)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Replace_Without_If_Match_Should_Force_Write()
{
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning")));
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
await _controller.Replace(
4,
new ReplaceTemplateRequest("Morning", []),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<ReplaceTemplateItems>(c => c.ExpectedVersion == Option<int>.None),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Replace_Should_Return_412_On_Precondition_Failed()
{
_controller.Request.Headers.IfMatch = "\"2\"";
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning", version: 5)));
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, List<TemplateItemViewModel>>(new PreconditionFailedError("stale")));
IActionResult result = await _controller.Replace(
4,
new ReplaceTemplateRequest("Morning", []),
CancellationToken.None);
var objectResult = result.ShouldBeOfType<ObjectResult>();
objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
}
[Test]
@@ -359,8 +447,8 @@ public class TemplateControllerTests
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
private static TemplateViewModel MakeTemplate(int id, int groupId, string name) =>
new(id, groupId, "Group", name);
private static TemplateViewModel MakeTemplate(int id, int groupId, string name, int version = 1) =>
new(id, groupId, "Group", name, version);
private static TemplateItemViewModel MakeItem(int blockId, string blockName, TimeSpan startTime, int minutes)
{
@@ -143,6 +143,9 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
[HttpGet("/api/deco-templates/{id:int}/items")]
[Tags("DecoTemplates")]
[EndpointSummary("Get deco template items")]
[EndpointDescription(
"Returns the deco template's items and a strong ETag of the deco template's version. Pass that ETag " +
"back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<DecoTemplateItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -154,6 +157,9 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
// The items GET returns children, not the root, so read the deco template's version for the ETag.
ConcurrencyHeaders.SetETag(Response, decoTemplate.Map(t => t.Version).IfNone(0));
List<DecoTemplateItemViewModel> items = await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
return new OkObjectResult(
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
@@ -164,16 +170,32 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
[EndpointSummary("Replace a deco template and its items")]
[EndpointDescription(
"Replaces the deco template's name and its full item list. Each item assigns a deco to a start/end time " +
"of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap.")]
"of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap. " +
"Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); " +
"a successful response carries the new ETag.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(DecoTemplateWithItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Replace(
int id,
[Required] [FromBody] ReplaceDecoTemplateRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return new BadRequestObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid If-Match header",
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
});
}
Option<DecoTemplateViewModel> maybeDecoTemplate =
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
if (maybeDecoTemplate.IsNone)
@@ -184,18 +206,23 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
int decoTemplateGroupId = maybeDecoTemplate.Map(t => t.DecoTemplateGroupId).IfNone(0);
Either<BaseError, List<DecoTemplateItemViewModel>> result =
await mediator.Send(request.ToCommand(decoTemplateGroupId, id), cancellationToken);
await mediator.Send(
request.ToCommand(decoTemplateGroupId, id, ifMatch.ExpectedVersion),
cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
Right: async items =>
{
Option<DecoTemplateViewModel> refreshed =
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
List<DecoTemplateItemViewModel> items =
await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
return refreshed.Match(
Some: vm => (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items)),
Some: vm =>
{
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
ConcurrencyHeaders.SetETag(Response, vm.Version);
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
},
None: () => ApiResults.NotFoundProblem());
});
}
@@ -4,10 +4,14 @@ namespace ErsatzTV.Controllers.Api.Requests;
public record ReplaceDecoTemplateRequest(string Name, List<DecoTemplateItemRequest> Items)
{
public ReplaceDecoTemplateItems ToCommand(int decoTemplateGroupId, int decoTemplateId) =>
public ReplaceDecoTemplateItems ToCommand(
int decoTemplateGroupId,
int decoTemplateId,
Option<int> expectedVersion = default) =>
new(
decoTemplateId,
decoTemplateGroupId,
Name,
(Items ?? []).Select(item => item.ToReplaceItem()).ToList());
(Items ?? []).Select(item => item.ToReplaceItem()).ToList(),
expectedVersion);
}
@@ -4,10 +4,11 @@ namespace ErsatzTV.Controllers.Api.Requests;
public record ReplaceTemplateRequest(string Name, List<TemplateItemRequest> Items)
{
public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId) =>
public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId, Option<int> expectedVersion = default) =>
new(
templateGroupId,
templateId,
Name,
(Items ?? []).Select(item => item.ToReplaceItem()).ToList());
(Items ?? []).Select(item => item.ToReplaceItem()).ToList(),
expectedVersion);
}
+31 -5
View File
@@ -134,6 +134,9 @@ public class TemplateController(IMediator mediator) : ControllerBase
[HttpGet("/api/templates/{id:int}/items")]
[Tags("Templates")]
[EndpointSummary("Get template items")]
[EndpointDescription(
"Returns the template's items and a strong ETag of the template's version. Pass that ETag back as " +
"If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<TemplateItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -145,6 +148,9 @@ public class TemplateController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
// The items GET returns children, not the root, so read the template's version for the ETag.
ConcurrencyHeaders.SetETag(Response, template.Map(t => t.Version).IfNone(0));
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
return new OkObjectResult(
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
@@ -155,16 +161,32 @@ public class TemplateController(IMediator mediator) : ControllerBase
[EndpointSummary("Replace a template and its items")]
[EndpointDescription(
"Replaces the template's name and its full item list. Each item assigns a block to a start time of day; " +
"items must not overlap (an item's end time is its start time plus the assigned block's duration).")]
"items must not overlap (an item's end time is its start time plus the assigned block's duration). " +
"Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); " +
"a successful response carries the new ETag.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(TemplateWithItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Replace(
int id,
[Required] [FromBody] ReplaceTemplateRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return new BadRequestObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid If-Match header",
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
});
}
Option<TemplateViewModel> maybeTemplate = await mediator.Send(new GetTemplateById(id), cancellationToken);
if (maybeTemplate.IsNone)
{
@@ -174,16 +196,20 @@ public class TemplateController(IMediator mediator) : ControllerBase
int templateGroupId = maybeTemplate.Map(t => t.TemplateGroupId).IfNone(0);
Either<BaseError, List<TemplateItemViewModel>> result =
await mediator.Send(request.ToCommand(templateGroupId, id), cancellationToken);
await mediator.Send(request.ToCommand(templateGroupId, id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
Right: async items =>
{
Option<TemplateViewModel> refreshed = await mediator.Send(new GetTemplateById(id), cancellationToken);
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
return refreshed.Match(
Some: vm => (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items)),
Some: vm =>
{
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
ConcurrencyHeaders.SetETag(Response, vm.Version);
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
},
None: () => ApiResults.NotFoundProblem());
});
}
+20 -3
View File
@@ -1,4 +1,4 @@
import { ApiError, request } from './client';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
export type DecoTemplateGroup = components['schemas']['DecoTemplateGroupResponseModel'];
@@ -46,8 +46,25 @@ export function getDecoTemplateItems(id: number): Promise<DecoTemplateItem[]> {
return request<DecoTemplateItem[]>(`/api/deco-templates/${id}/items`);
}
export function replaceDecoTemplate(id: number, body: ReplaceDecoTemplateRequest): Promise<DecoTemplateWithItems> {
return request<DecoTemplateWithItems>(`/api/deco-templates/${id}`, { body, method: 'PUT' });
/** Load deco template items together with the deco template's concurrency ETag (issue #253). */
export function getDecoTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<DecoTemplateItem[]>> {
return requestWithMeta<DecoTemplateItem[]>(`/api/deco-templates/${id}/items`);
}
/**
* Replace a deco template. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with
* 412; the resolved value carries the new ETag for a subsequent save (issue #253).
*/
export function replaceDecoTemplate(
id: number,
body: ReplaceDecoTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<DecoTemplateWithItems>> {
return requestWithMeta<DecoTemplateWithItems>(`/api/deco-templates/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function messageFromDecoTemplateError(error: unknown, fallback = 'Unable to load deco templates'): string {
+20 -3
View File
@@ -1,4 +1,4 @@
import { ApiError, request } from './client';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
export type TemplateGroup = components['schemas']['TemplateGroupResponseModel'];
@@ -47,8 +47,25 @@ export function getTemplateItems(id: number): Promise<TemplateItem[]> {
return request<TemplateItem[]>(`/api/templates/${id}/items`);
}
export function replaceTemplate(id: number, body: ReplaceTemplateRequest): Promise<TemplateWithItems> {
return request<TemplateWithItems>(`/api/templates/${id}`, { body, method: 'PUT' });
/** Load template items together with the template's concurrency ETag (issue #253). */
export function getTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<TemplateItem[]>> {
return requestWithMeta<TemplateItem[]>(`/api/templates/${id}/items`);
}
/**
* Replace a template. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with 412;
* the resolved value carries the new ETag for a subsequent save (issue #253).
*/
export function replaceTemplate(
id: number,
body: ReplaceTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<TemplateWithItems>> {
return requestWithMeta<TemplateWithItems>(`/api/templates/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function copyTemplate(id: number, body: CopyTemplateRequest): Promise<Template> {
@@ -185,4 +185,42 @@ describe('DecoTemplatesScreen', () => {
expect(screen.queryByText(/must start before it ends/)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save deco template/ })).not.toBeDisabled();
});
it('shows a conflict dialog and reloads when the deco template changed elsewhere (412)', async () => {
window.history.pushState({}, '', '/app/deco-templates/4');
let putCount = 0;
const fetchMock = mockApi({
items: [{ decoId: 10, decoName: 'Sunrise', startTime: '06:00:00', endTime: '07:00:00' }],
onRequest: (url, method) => {
if (url === '/api/deco-templates/4' && method === 'PUT') {
putCount += 1;
if (putCount === 1) {
return new Response(
JSON.stringify({ status: 412, title: 'Precondition Failed', detail: 'stale' }),
{ headers: { 'Content-Type': 'application/json' }, status: 412 }
);
}
}
return null;
}
});
render(<DecoTemplatesScreen />);
await waitFor(() => expect(screen.getByDisplayValue('Bumpers')).toBeInTheDocument());
const itemsGetCount = () =>
fetchMock.mock.calls.filter(
([u, init]) => u === '/api/deco-templates/4/items' && (init?.method ?? 'GET') === 'GET'
).length;
const before = itemsGetCount();
fireEvent.click(screen.getByRole('button', { name: /Save deco template/ }));
// A 412 opens the "changed elsewhere" dialog rather than showing a generic save error.
expect(await screen.findByText(/Reload to get the latest version/i)).toBeInTheDocument();
// Reloading re-fetches the deco template items.
fireEvent.click(screen.getByRole('button', { name: /^Reload$/ }));
await waitFor(() => expect(itemsGetCount()).toBeGreaterThan(before));
});
});
+61 -22
View File
@@ -3,6 +3,7 @@ import { ArrowLeft, Check, FolderPlus, Plus, Trash2, TriangleAlert } from 'lucid
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components';
import {
ApiError,
createDecoTemplate,
createDecoTemplateGroup,
deleteDecoTemplate,
@@ -10,7 +11,7 @@ import {
getDecoGroups,
getDecoTemplate,
getDecoTemplateGroups,
getDecoTemplateItems,
getDecoTemplateItemsWithMeta,
getDecoTemplates,
getDecos,
messageFromDecoTemplateError,
@@ -477,6 +478,11 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
const [loadError, setLoadError] = useState<null | string>(null);
const [saveError, setSaveError] = useState<null | string>(null);
const [saving, setSaving] = useState(false);
const [conflictOpen, setConflictOpen] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
// Concurrency ETag (issue #253): captured from the items GET, sent as If-Match on save, and
// replaced from the PUT response on every successful save.
const etagRef = useRef<string | null>(null);
const [addGroupId, setAddGroupId] = useState('');
const [addDecoId, setAddDecoId] = useState('');
@@ -487,34 +493,38 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
useEffect(() => {
let active = true;
Promise.all([
getDecoTemplate(decoTemplateId),
getDecoTemplateItems(decoTemplateId),
getDecoGroups(),
getDecos()
])
.then(([decoTemplateData, items, groupData, decoData]) => {
// Read items + ETag FIRST, then the root metadata, mirroring BlockEditor (issue #253): the ETag
// must be no newer than every piece of versioned data the draft is built from.
void (async () => {
try {
const itemsMeta = await getDecoTemplateItemsWithMeta(decoTemplateId);
const [decoTemplateData, groupData, decoData] = await Promise.all([
getDecoTemplate(decoTemplateId),
getDecoGroups(),
getDecos()
]);
if (!active) {
return;
}
etagRef.current = itemsMeta.etag;
setDecoTemplate(decoTemplateData);
setDecoGroups(groupData);
setDecos(decoData);
setDraft({
name: decoTemplateData.name,
items: items.map(itemFromResponse)
items: itemsMeta.data.map(itemFromResponse)
});
})
.catch((error: unknown) => {
} catch (error) {
if (active) {
setLoadError(messageFromDecoTemplateError(error, 'Unable to load deco template'));
}
});
}
})();
return () => {
active = false;
};
}, [decoTemplateId]);
}, [decoTemplateId, reloadKey]);
if (loadError) {
return (
@@ -590,22 +600,40 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
setSaving(true);
setSaveError(null);
try {
await replaceDecoTemplate(decoTemplateId, {
name: draft.name.trim(),
items: draft.items.map((item) => ({
decoId: item.decoId,
startTime: item.startTime,
endTime: item.endTime
}))
});
const { etag } = await replaceDecoTemplate(
decoTemplateId,
{
name: draft.name.trim(),
items: draft.items.map((item) => ({
decoId: item.decoId,
startTime: item.startTime,
endTime: item.endTime
}))
},
etagRef.current
);
etagRef.current = etag;
navigateToPath(BASE_PATH);
} catch (error) {
setSaveError(messageFromDecoTemplateError(error, 'Unable to save deco template'));
if (error instanceof ApiError && error.status === 412) {
// Another edit landed since we loaded — force a reload rather than overwriting it (#253).
setConflictOpen(true);
} else {
setSaveError(messageFromDecoTemplateError(error, 'Unable to save deco template'));
}
} finally {
setSaving(false);
}
};
const reloadAfterConflict = () => {
setConflictOpen(false);
setSaveError(null);
setDraft(null);
setDecoTemplate(null);
setReloadKey((key) => key + 1);
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -758,6 +786,17 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
</table>
)}
</Card>
<ConfirmDialog
cancelLabel="Keep editing"
confirmLabel="Reload"
message="This deco template was changed elsewhere since you opened it. Reload to get the latest version — your unsaved changes will be discarded."
onCancel={() => setConflictOpen(false)}
onConfirm={reloadAfterConflict}
open={conflictOpen}
title="Deco template changed elsewhere"
tone="danger"
/>
</div>
);
}
+37
View File
@@ -242,4 +242,41 @@ describe('TemplatesScreen', () => {
expect(await screen.findByText(/overlaps/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save template/ })).toBeDisabled();
});
it('shows a conflict dialog and reloads when the template changed elsewhere (412)', async () => {
window.history.pushState({}, '', '/app/templates/4');
let putCount = 0;
const fetchMock = mockApi({
items: [{ blockId: 10, blockName: 'Cartoons', blockMinutes: 60, startTime: '06:00:00' }],
onRequest: (url, method) => {
if (url === '/api/templates/4' && method === 'PUT') {
putCount += 1;
if (putCount === 1) {
return new Response(
JSON.stringify({ status: 412, title: 'Precondition Failed', detail: 'stale' }),
{ headers: { 'Content-Type': 'application/json' }, status: 412 }
);
}
}
return null;
}
});
render(<TemplatesScreen />);
await waitFor(() => expect(screen.getByDisplayValue('Weekdays')).toBeInTheDocument());
const itemsGetCount = () =>
fetchMock.mock.calls.filter(([u, init]) => u === '/api/templates/4/items' && (init?.method ?? 'GET') === 'GET')
.length;
const before = itemsGetCount();
fireEvent.click(screen.getByRole('button', { name: /Save template/ }));
// A 412 opens the "changed elsewhere" dialog rather than showing a generic save error.
expect(await screen.findByText(/Reload to get the latest version/i)).toBeInTheDocument();
// Reloading re-fetches the template items.
fireEvent.click(screen.getByRole('button', { name: /^Reload$/ }));
await waitFor(() => expect(itemsGetCount()).toBeGreaterThan(before));
});
});
+57 -13
View File
@@ -3,6 +3,7 @@ import { ArrowLeft, Check, Copy, FolderPlus, Plus, Search, Trash2, TriangleAlert
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components';
import {
ApiError,
copyTemplate,
createTemplate,
createTemplateGroup,
@@ -12,7 +13,7 @@ import {
getBlocks,
getTemplate,
getTemplateGroups,
getTemplateItems,
getTemplateItemsWithMeta,
getTemplates,
messageFromTemplateError,
replaceTemplate,
@@ -554,6 +555,11 @@ function TemplateEditor({ templateId }: { templateId: number }) {
const [loadError, setLoadError] = useState<null | string>(null);
const [saveError, setSaveError] = useState<null | string>(null);
const [saving, setSaving] = useState(false);
const [conflictOpen, setConflictOpen] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
// Concurrency ETag (issue #253): captured from the items GET, sent as If-Match on save, and
// replaced from the PUT response on every successful save.
const etagRef = useRef<string | null>(null);
const [addGroupId, setAddGroupId] = useState('');
const [addBlockId, setAddBlockId] = useState('');
@@ -562,29 +568,38 @@ function TemplateEditor({ templateId }: { templateId: number }) {
useEffect(() => {
let active = true;
Promise.all([getTemplate(templateId), getTemplateItems(templateId), getBlockGroups(), getBlocks()])
.then(([templateData, items, groupData, blockData]) => {
// Read items + ETag FIRST, then the root metadata, mirroring BlockEditor (issue #253): the ETag
// must be no newer than every piece of versioned data the draft is built from.
void (async () => {
try {
const itemsMeta = await getTemplateItemsWithMeta(templateId);
const [templateData, groupData, blockData] = await Promise.all([
getTemplate(templateId),
getBlockGroups(),
getBlocks()
]);
if (!active) {
return;
}
etagRef.current = itemsMeta.etag;
setTemplate(templateData);
setBlockGroups(groupData);
setBlocks(blockData);
setDraft({
name: templateData.name,
items: items.map(itemFromResponse)
items: itemsMeta.data.map(itemFromResponse)
});
})
.catch((error: unknown) => {
} catch (error) {
if (active) {
setLoadError(messageFromTemplateError(error, 'Unable to load template'));
}
});
}
})();
return () => {
active = false;
};
}, [templateId]);
}, [templateId, reloadKey]);
if (loadError) {
return (
@@ -659,18 +674,36 @@ function TemplateEditor({ templateId }: { templateId: number }) {
setSaving(true);
setSaveError(null);
try {
await replaceTemplate(templateId, {
name: draft.name.trim(),
items: draft.items.map((item) => ({ blockId: item.blockId, startTime: item.startTime }))
});
const { etag } = await replaceTemplate(
templateId,
{
name: draft.name.trim(),
items: draft.items.map((item) => ({ blockId: item.blockId, startTime: item.startTime }))
},
etagRef.current
);
etagRef.current = etag;
navigateToPath(BASE_PATH);
} catch (error) {
setSaveError(messageFromTemplateError(error, 'Unable to save template'));
if (error instanceof ApiError && error.status === 412) {
// Another edit landed since we loaded — force a reload rather than overwriting it (#253).
setConflictOpen(true);
} else {
setSaveError(messageFromTemplateError(error, 'Unable to save template'));
}
} finally {
setSaving(false);
}
};
const reloadAfterConflict = () => {
setConflictOpen(false);
setSaveError(null);
setDraft(null);
setTemplate(null);
setReloadKey((key) => key + 1);
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -796,6 +829,17 @@ function TemplateEditor({ templateId }: { templateId: number }) {
</table>
)}
</Card>
<ConfirmDialog
cancelLabel="Keep editing"
confirmLabel="Reload"
message="This template was changed elsewhere since you opened it. Reload to get the latest version — your unsaved changes will be discarded."
onCancel={() => setConflictOpen(false)}
onConfirm={reloadAfterConflict}
open={conflictOpen}
title="Template changed elsewhere"
tone="danger"
/>
</div>
);
}