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:
@@ -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,15 +45,26 @@ 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.
|
||||
// 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)
|
||||
@@ -53,7 +73,9 @@ public class ReplaceDecoTemplateItemsHandler(
|
||||
|
||||
foreach (int playoutId in playoutIds)
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset), CancellationToken.None);
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playoutId, PlayoutBuildMode.Reset),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
await dbContext.Entry(decoTemplate)
|
||||
@@ -62,7 +84,10 @@ public class ReplaceDecoTemplateItemsHandler(
|
||||
.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));
|
||||
// }
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+164
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
// 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),
|
||||
getDecoTemplateItems(decoTemplateId),
|
||||
getDecoGroups(),
|
||||
getDecos()
|
||||
])
|
||||
.then(([decoTemplateData, items, groupData, decoData]) => {
|
||||
]);
|
||||
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, {
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user