merge: #253 PR2 schedule-items

This commit is contained in:
2026-07-11 18:38:14 +02:00
16 changed files with 506 additions and 30 deletions
@@ -52,6 +52,9 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
ProgramScheduleItem item = BuildItem(programSchedule, nextIndex, request); ProgramScheduleItem item = BuildItem(programSchedule, nextIndex, request);
programSchedule.Items.Add(item); programSchedule.Items.Add(item);
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
programSchedule.Version++;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
// refresh any playouts that use this schedule // refresh any playouts that use this schedule
@@ -34,6 +34,10 @@ public class DeleteProgramScheduleItemHandler(
List<Playout> playouts = item.ProgramSchedule.Playouts; List<Playout> playouts = item.ProgramSchedule.Playouts;
dbContext.ProgramScheduleItems.Remove(item); dbContext.ProgramScheduleItems.Remove(item);
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
item.ProgramSchedule.Version++;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
foreach (Playout playout in playouts) foreach (Playout playout in playouts)
@@ -44,5 +44,8 @@ public record ReplaceProgramScheduleItem(
string PreferredSubtitleLanguageCode, string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode) : IProgramScheduleItemRequest; ChannelSubtitleMode? SubtitleMode) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest< public record ReplaceProgramScheduleItems(
int ProgramScheduleId,
List<ReplaceProgramScheduleItem> Items,
Option<int> ExpectedVersion = default) : IRequest<
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>; Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>;
@@ -26,13 +26,23 @@ public class ReplaceProgramScheduleItemsHandler(
Some: async programSchedule => Some: async programSchedule =>
{ {
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule); Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
return await validation.Apply(ps => PersistItems(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, ProgramSchedule> validated = LanguageExtensions.ToEither(validation)
.Bind(ps => ps.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: ps => PersistItems(dbContext, request, ps, cancellationToken),
Left: error =>
Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(error));
}, },
None: () => Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>( None: () => Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(
new NotFoundError("[ProgramScheduleId] does not exist."))); new NotFoundError("[ProgramScheduleId] does not exist.")));
} }
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems( private async Task<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>> PersistItems(
TvContext dbContext, TvContext dbContext,
ReplaceProgramScheduleItems request, ReplaceProgramScheduleItems request,
ProgramSchedule programSchedule, ProgramSchedule programSchedule,
@@ -92,7 +102,20 @@ public class ReplaceProgramScheduleItemsHandler(
programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i])); programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i]));
} }
await dbContext.SaveChangesAsync(cancellationToken); // Unconditional bump: this handler frequently saves with only CHILD changes and no root-scalar
// change, so without an explicit bump EF would emit no root UPDATE and the concurrency token
// would never fire (nor rotate other clients' ETags). Bumping guarantees both on every save,
// including a no-op same-items PUT-back (issue #253 / api-conventions §7a).
programSchedule.Version++;
// 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. On failure, propagate the error WITHOUT
// running the post-save reload/enqueue below.
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
if (saved.IsLeft)
{
return saved.Map(_ => (IEnumerable<ProgramScheduleItemViewModel>)[]);
}
// refresh any playouts that use this schedule // refresh any playouts that use this schedule
foreach (Playout playout in programSchedule.Playouts) foreach (Playout playout in programSchedule.Playouts)
@@ -54,6 +54,9 @@ public class UpdateProgramScheduleHandler(
programSchedule.RandomStartPoint = request.RandomStartPoint; programSchedule.RandomStartPoint = request.RandomStartPoint;
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior; programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
programSchedule.Version++;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
if (needToRefreshPlayout) if (needToRefreshPlayout)
@@ -12,7 +12,8 @@ internal static class Mapper
programSchedule.TreatCollectionsAsShows, programSchedule.TreatCollectionsAsShows,
programSchedule.ShuffleScheduleItems, programSchedule.ShuffleScheduleItems,
programSchedule.RandomStartPoint, programSchedule.RandomStartPoint,
programSchedule.FixedStartTimeBehavior); programSchedule.FixedStartTimeBehavior,
programSchedule.Version);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) => internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
programScheduleItem switch programScheduleItem switch
@@ -9,4 +9,5 @@ public record ProgramScheduleViewModel(
bool TreatCollectionsAsShows, bool TreatCollectionsAsShows,
bool ShuffleScheduleItems, bool ShuffleScheduleItems,
bool RandomStartPoint, bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior); FixedStartTimeBehavior FixedStartTimeBehavior,
int Version);
@@ -19,7 +19,8 @@ public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContex
ps.TreatCollectionsAsShows, ps.TreatCollectionsAsShows,
ps.ShuffleScheduleItems, ps.ShuffleScheduleItems,
ps.RandomStartPoint, ps.RandomStartPoint,
ps.FixedStartTimeBehavior)) ps.FixedStartTimeBehavior,
ps.Version))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
} }
@@ -0,0 +1,231 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
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.ProgramSchedules;
/// <summary>
/// Contract tests for the #253 optimistic-concurrency mechanic on the ProgramSchedule / schedule-items
/// aggregate: the handler pre-check (stale If-Match → 412, before the positional reconcile runs — so the
/// item rows AND persisted fill-group/shuffle state are left untouched), the force-write path (no
/// If-Match), the unconditional Version bump on every save (including a no-op same-items PUT-back where
/// only child rows change), 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 ProgramSchedule and the losing save silently succeeds instead
/// of mapping to a <see cref="PreconditionFailedError" />.
/// </summary>
[TestFixture]
public class ReplaceProgramScheduleItemsHandlerConcurrencyTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
// Seeds a schedule (Id=1) with a single One/SearchQuery item (Id=1) and a persisted fill-group
// enumerator state pointing at that item, so the stale-If-Match test can prove the reconcile never ran.
private async Task SeedScheduleAsync(int version)
{
await using TvContext ctx = _db.CreateContext();
ctx.ProgramSchedules.Add(
new ProgramSchedule
{
Id = 1,
Name = "Concurrency",
Version = version,
Items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemOne
{
Id = 1,
Index = 0,
CollectionType = CollectionType.SearchQuery,
SearchTitle = "a",
SearchQuery = "a",
PlaybackOrder = PlaybackOrder.Shuffle,
GuideMode = GuideMode.Normal
}
},
Playouts = [],
ProgramScheduleAlternates = []
});
await ctx.SaveChangesAsync();
ctx.Add(new PlayoutScheduleItemFillGroupIndex
{
PlayoutId = 1,
ProgramScheduleItemId = 1,
EnumeratorState = new CollectionEnumeratorState { Seed = 12345, Index = 7 }
});
await ctx.SaveChangesAsync();
}
private static ReplaceProgramScheduleItems Command(
Option<int> expectedVersion,
List<ReplaceProgramScheduleItem>? items = null) =>
new(1, items ?? [MakeItem(0, PlayoutMode.One, "a")], expectedVersion);
private async Task<int> ReadVersionAsync()
{
await using TvContext ctx = _db.CreateContext();
return await ctx.ProgramSchedules.Where(s => s.Id == 1).Select(s => s.Version).SingleAsync();
}
private static BaseError? LeftOrNull(Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result) =>
result.Match<BaseError?>(Right: _ => null, Left: e => e);
[Test]
public async Task Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
{
await SeedScheduleAsync(version: 2);
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
// An empty item list WOULD delete the existing item (and cascade its fill-group state) if the
// reconcile ran. A stale If-Match must reject before that, leaving everything untouched.
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await handler.Handle(Command(Some(1), []), CancellationToken.None);
LeftOrNull(result).ShouldBeOfType<PreconditionFailedError>();
(await ReadVersionAsync()).ShouldBe(2);
await using TvContext ctx = _db.CreateContext();
(await ctx.ProgramScheduleItems.CountAsync(i => i.ProgramScheduleId == 1)).ShouldBe(1);
// The reconcile never ran: the fill-group enumerator state is exactly as seeded.
PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
.Include(x => x.EnumeratorState)
.SingleAsync();
fillGroup.ProgramScheduleItemId.ShouldBe(1);
fillGroup.EnumeratorState.Seed.ShouldBe(12345);
fillGroup.EnumeratorState.Index.ShouldBe(7);
}
[Test]
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
{
await SeedScheduleAsync(version: 2);
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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 SeedScheduleAsync(version: 2);
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
// None expected version = Phase-1 force-write regardless of the stored version.
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await handler.Handle(Command(None), CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersionAsync()).ShouldBe(3);
}
[Test]
public async Task NoOp_Save_Should_Bump_Version_Even_With_Only_Child_Changes()
{
await SeedScheduleAsync(version: 5);
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
// Same content twice: this handler saves with only CHILD changes and no root-scalar change, so the
// unconditional bump (M1) must still rotate the version each time, otherwise a no-op PUT-back would
// neither fire the token nor 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 SeedScheduleAsync(version: 1);
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
// ProgramSchedule 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();
ProgramSchedule winner = await ctxWinner.ProgramSchedules.SingleAsync(s => s.Id == 1);
ProgramSchedule loser = await ctxLoser.ProgramSchedules.SingleAsync(s => s.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);
loserResult.Match<BaseError?>(Right: _ => null, Left: e => e).ShouldBeOfType<PreconditionFailedError>();
// Non-vacuous: the winner's write stuck at exactly its +1; the loser did not overwrite it.
(await ReadVersionAsync()).ShouldBe(2);
}
private static ReplaceProgramScheduleItem MakeItem(int index, PlayoutMode mode, string searchQuery) =>
new(
index,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
mode,
CollectionType.SearchQuery,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: searchQuery,
SearchQuery: searchQuery,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode.None,
MultipleMode.Count,
MultipleCount: "1",
PlayoutDuration: null,
TailMode.None,
DiscardToFillAttempts: null,
CustomTitle: null,
GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
}
@@ -1247,7 +1247,7 @@ public class PlayoutControllerTests
new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null); new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
private static ProgramScheduleViewModel MakeScheduleVm(int id) => private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict); new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, 0);
private static TemplateViewModel MakeTemplateViewModel(int id) => private static TemplateViewModel MakeTemplateViewModel(int id) =>
new(id, 1, "Group", $"Template {id}", 0); new(id, 1, "Group", $"Template {id}", 0);
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute; using NSubstitute;
@@ -30,7 +31,12 @@ public class ScheduleControllerTests
public void SetUp() public void SetUp()
{ {
_mediator = Substitute.For<IMediator>(); _mediator = Substitute.For<IMediator>();
_controller = new ScheduleController(_mediator); _controller = new ScheduleController(_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] [Test]
@@ -271,6 +277,90 @@ public class ScheduleControllerTests
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
[Test]
public async Task GetItems_Should_Set_ETag_From_Schedule_Version()
{
var response = new ProgramScheduleItemsWithDurationViewModel([], null);
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 9)));
_mediator.Send(Arg.Any<GetProgramScheduleItemsWithDurations>(), Arg.Any<CancellationToken>())
.Returns(response);
await _controller.GetItems(4, CancellationToken.None);
_controller.Response.Headers.ETag.ToString().ShouldBe("\"9\"");
}
[Test]
public async Task ReplaceItems_Should_Return_400_On_Malformed_If_Match()
{
_controller.Request.Headers.IfMatch = "not-an-etag";
IActionResult result = await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
CancellationToken.None);
result.ShouldBeOfType<BadRequestObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceItems_Should_Thread_If_Match_Version_Into_Command_And_Set_New_ETag()
{
_controller.Request.Headers.IfMatch = "\"3\"";
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 4)));
IActionResult result = await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
// On success the response carries the refreshed schedule's ETag.
_controller.Response.Headers.ETag.ToString().ShouldBe("\"4\"");
await _mediator.Received(1).Send(
Arg.Is<ReplaceProgramScheduleItems>(c => c.ExpectedVersion == Option<int>.Some(3)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceItems_Without_If_Match_Should_Force_Write()
{
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 1)));
await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<ReplaceProgramScheduleItems>(c => c.ExpectedVersion == Option<int>.None),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceItems_Should_Return_412_On_Precondition_Failed()
{
_controller.Request.Headers.IfMatch = "\"2\"";
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(new PreconditionFailedError("stale")));
IActionResult result = await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
CancellationToken.None);
var objectResult = result.ShouldBeOfType<ObjectResult>();
objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
}
[Test] [Test]
public async Task DeleteItem_Should_Return_204_And_Map_Route_Ids() public async Task DeleteItem_Should_Return_204_And_Map_Route_Ids()
{ {
@@ -343,8 +433,8 @@ public class ScheduleControllerTests
PreferredSubtitleLanguageCode: null, PreferredSubtitleLanguageCode: null,
SubtitleMode: null); SubtitleMode: null);
private static ProgramScheduleViewModel MakeSchedule(int id, string name) => private static ProgramScheduleViewModel MakeSchedule(int id, string name, int version = 0) =>
new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible); new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible, version);
private static ProgramScheduleItemOneViewModel MakeOneItem(int id) => private static ProgramScheduleItemOneViewModel MakeOneItem(int id) =>
new( new(
@@ -4,8 +4,9 @@ namespace ErsatzTV.Controllers.Api.Requests;
public record ReplaceScheduleItemsRequest(List<ScheduleItemRequest> Items) public record ReplaceScheduleItemsRequest(List<ScheduleItemRequest> Items)
{ {
public ReplaceProgramScheduleItems ToCommand(int scheduleId) => public ReplaceProgramScheduleItems ToCommand(int scheduleId, Option<int> expectedVersion = default) =>
new( new(
scheduleId, scheduleId,
(Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList()); (Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList(),
expectedVersion);
} }
+38 -5
View File
@@ -104,7 +104,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
[EndpointDescription( [EndpointDescription(
"Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a " + "Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a " +
"nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " + "nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " +
"derived from referenced collection/media runtimes and are null when unbounded or unknown.")] "derived from referenced collection/media runtimes and are null when unbounded or unknown. The " +
"response also carries a strong ETag of the schedule's version; pass that ETag back as If-Match on " +
"the replace (PUT) to detect a concurrent edit (issue #253).")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -116,6 +118,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem(); return ApiResults.NotFoundProblem();
} }
// The items GET returns children, not the root, so read the schedule's version for the ETag.
ConcurrencyHeaders.SetETag(Response, schedule.Map(s => s.Version).IfNone(0));
ProgramScheduleItemsWithDurationViewModel items = ProgramScheduleItemsWithDurationViewModel items =
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken); await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items)); return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items));
@@ -143,20 +148,48 @@ public class ScheduleController(IMediator mediator) : ControllerBase
[HttpPut("/api/schedules/{id:int}/items")] [HttpPut("/api/schedules/{id:int}/items")]
[Tags("Schedules")] [Tags("Schedules")]
[EndpointSummary("Replace schedule items")] [EndpointSummary("Replace schedule items")]
[EndpointDescription(
"Replaces the schedule's full item list; item indexes are assigned from the array order. 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")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ScheduleItemResponseModel>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<ScheduleItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceItems( public async Task<IActionResult> ReplaceItems(
int id, int id,
[Required] [FromBody] ReplaceScheduleItemsRequest request, [Required] [FromBody] ReplaceScheduleItemsRequest request,
CancellationToken cancellationToken) 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 \"*\"."
});
}
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result = Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await mediator.Send(request.ToCommand(id), cancellationToken); await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
return result
.Map(items => items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList()) return await result.Match(
.ToUpdatedResult(); Left: error => Task.FromResult(error.ToErrorResult()),
Right: async items =>
{
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
Option<ProgramScheduleViewModel> refreshed =
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
refreshed.IfSome(vm => ConcurrencyHeaders.SetETag(Response, vm.Version));
return (IActionResult)new OkObjectResult(
items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList());
});
} }
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")] [HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]
+20 -6
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'; import type { components } from './generated/v1';
// FillerPreset is exported by ./pickers — import (don't re-export) to avoid a duplicate `export *` // FillerPreset is exported by ./pickers — import (don't re-export) to avoid a duplicate `export *`
// name in api/index.ts. LanguageCode/getLanguages moved to ./languages (also re-exported via // name in api/index.ts. LanguageCode/getLanguages moved to ./languages (also re-exported via
@@ -46,17 +46,31 @@ export function getScheduleItems(scheduleId: number): Promise<ScheduleItemsRespo
return request<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`); return request<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
} }
/** Load schedule items together with the schedule's concurrency ETag (issue #253). */
export function getScheduleItemsWithMeta(
scheduleId: number
): Promise<ResponseWithMeta<ScheduleItemsResponse>> {
return requestWithMeta<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ScheduleItem> { export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ScheduleItem> {
return request<ScheduleItem>(`/api/schedules/${scheduleId}/items`, { body, method: 'POST' }); return request<ScheduleItem>(`/api/schedules/${scheduleId}/items`, { body, method: 'POST' });
} }
// Destructive replace: the server deletes+recreates every item row (new ids) and triggers playout // Positional in-place reconcile: the server reuses same-typed item rows (keeping fill-group state) and
// rebuilds. The editor batches all local draft edits into this single call. See docs/decisions.md. // triggers playout rebuilds. The editor batches all local draft edits into this single call. 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). See docs/decisions.md.
export function replaceScheduleItems( export function replaceScheduleItems(
scheduleId: number, scheduleId: number,
body: ReplaceScheduleItemsRequest body: ReplaceScheduleItemsRequest,
): Promise<ScheduleItem[]> { ifMatch?: string | null
return request<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, { body, method: 'PUT' }); ): Promise<ResponseWithMeta<ScheduleItem[]>> {
return requestWithMeta<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
} }
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> { export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
+30
View File
@@ -267,6 +267,36 @@ describe('SchedulesScreen — error + dirty handling', () => {
expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(false); expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(false);
}); });
it('412 on Save opens the conflict dialog; Reload discards the draft and reloads', async () => {
let putCount = 0;
const handle = await renderReady({
onRequest: (url, method) => {
if (url === '/api/schedules/1/items' && method === 'PUT') {
putCount += 1;
return jsonResponse({ title: 'Precondition Failed', detail: 'stale' }, 412);
}
return null;
}
});
// Make dirty, then Save → 412.
fireEvent.click(screen.getByRole('button', { name: /Add item/ }));
const getsBefore = handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'GET').length;
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
// The conflict dialog appears (distinct from the generic save-error path).
await screen.findByText('Schedule changed elsewhere');
expect(putCount).toBe(1);
// Reload discards the draft and re-fetches the active schedule's items.
fireEvent.click(screen.getByRole('button', { name: /^Reload$/ }));
await waitFor(() =>
expect(handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'GET').length)
.toBeGreaterThan(getsBefore));
// Dialog closed; draft reset to the (single-item) server baseline.
await waitFor(() => expect(screen.queryByText('Schedule changed elsewhere')).toBeNull());
expect(within(screen.getByLabelText('Schedule lineup')).getAllByRole('listitem')).toHaveLength(1);
});
it('guards a schedule switch when dirty (confirm=false aborts)', async () => { it('guards a schedule switch when dirty (confirm=false aborts)', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false); vi.spyOn(window, 'confirm').mockReturnValue(false);
const handle = await renderReady({ schedules: [schedule, { ...schedule, id: 2, name: 'Late' }] }); const handle = await renderReady({ schedules: [schedule, { ...schedule, id: 2, name: 'Late' }] });
+44 -6
View File
@@ -10,12 +10,13 @@ import {
Spinner Spinner
} from '../components'; } from '../components';
import { import {
ApiError,
deleteSchedule, deleteSchedule,
getFillerPresetsByKind, getFillerPresetsByKind,
getLanguages, getLanguages,
getPlaylistGroups, getPlaylistGroups,
getRerunCollections, getRerunCollections,
getScheduleItems, getScheduleItemsWithMeta,
getSchedules, getSchedules,
getGraphicsElements, getGraphicsElements,
getWatermarks, getWatermarks,
@@ -87,11 +88,16 @@ export function SchedulesScreen() {
const [mutationError, setMutationError] = useState<string | null>(null); const [mutationError, setMutationError] = useState<string | null>(null);
const [form, setForm] = useState<null | 'create' | 'edit'>(null); const [form, setForm] = useState<null | 'create' | 'edit'>(null);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
// Opened when a save 412s because the schedule was changed elsewhere since it was loaded (#253).
const [conflictOpen, setConflictOpen] = useState(false);
const activeRef = useRef(true); const activeRef = useRef(true);
const dirtyRef = useRef(false); const dirtyRef = useRef(false);
const baselineRef = useRef<DraftItem[]>([]); const baselineRef = useRef<DraftItem[]>([]);
const itemSeq = useRef(0); const itemSeq = useRef(0);
// Last-seen concurrency ETag: set from the items GET, replaced by every successful save's response
// ETag (a same-tab second save must use the new tag or it would 412 against its own write) (#253).
const etagRef = useRef<string | null>(null);
const setDirtyState = useCallback((value: boolean) => { const setDirtyState = useCallback((value: boolean) => {
dirtyRef.current = value; dirtyRef.current = value;
@@ -131,11 +137,13 @@ export function SchedulesScreen() {
// ---- Load items for the active schedule -------------------------------- // ---- Load items for the active schedule --------------------------------
const loadItems = useCallback((scheduleId: number) => { const loadItems = useCallback((scheduleId: number) => {
const seq = ++itemSeq.current; const seq = ++itemSeq.current;
getScheduleItems(scheduleId) getScheduleItemsWithMeta(scheduleId)
.then((response) => { .then(({ data: response, etag }) => {
if (!activeRef.current || itemSeq.current !== seq) { if (!activeRef.current || itemSeq.current !== seq) {
return; return;
} }
// Only the current load owns the ETag (same stale-guard as the items below).
etagRef.current = etag;
// Defensive: array position becomes the persisted index on the next PUT, so ingest strictly by the // Defensive: array position becomes the persisted index on the next PUT, so ingest strictly by the
// server-provided `index` rather than trusting response row order (see #229 — the API now orders, but // server-provided `index` rather than trusting response row order (see #229 — the API now orders, but
// the SPA must not silently reshuffle the lineup if that guarantee ever regresses). // the SPA must not silently reshuffle the lineup if that guarantee ever regresses).
@@ -318,11 +326,14 @@ export function SchedulesScreen() {
} }
setSaving(true); setSaving(true);
setMutationError(null); setMutationError(null);
replaceScheduleItems(activeId, { items: items.map(normalizeForSave) }) replaceScheduleItems(activeId, { items: items.map(normalizeForSave) }, etagRef.current)
.then((response) => { .then(({ data: response, etag }) => {
if (!activeRef.current) { if (!activeRef.current) {
return; return;
} }
// Load-bearing: a same-tab second save must use the ETag this write produced, or it would 412
// against its own change (#253).
etagRef.current = etag;
const drafts = [...response].sort((a, b) => a.index - b.index).map(fromResponse); const drafts = [...response].sort((a, b) => a.index - b.index).map(fromResponse);
baselineRef.current = drafts; baselineRef.current = drafts;
setItems(drafts); setItems(drafts);
@@ -337,11 +348,28 @@ export function SchedulesScreen() {
if (!activeRef.current) { if (!activeRef.current) {
return; return;
} }
setMutationError(messageFromScheduleError(error, 'Unable to save schedule items')); if (error instanceof ApiError && error.status === 412) {
// The schedule was changed elsewhere since we loaded it — force a reload rather than
// overwriting the fresher edit (#253). Distinct from all other errors.
setConflictOpen(true);
} else {
setMutationError(messageFromScheduleError(error, 'Unable to save schedule items'));
}
setSaving(false); setSaving(false);
}); });
}; };
// Conflict "Reload": discard the dirty draft and re-run the load for the active schedule. loadItems
// already bumps itemSeq (stale-guard), re-seeds baselineRef, clears dirty, and captures the new ETag —
// so there's no separate reloadKey to invent (#253).
const reloadAfterConflict = () => {
setConflictOpen(false);
setMutationError(null);
if (activeId != null) {
loadItems(activeId);
}
};
// Opening the properties editor is blocked while the item draft is dirty (confirm-to-discard, same // Opening the properties editor is blocked while the item draft is dirty (confirm-to-discard, same
// semantics as guardedSwitch). This is the smaller, fully-consistent fix for the shuffle-toggle // semantics as guardedSwitch). This is the smaller, fully-consistent fix for the shuffle-toggle
// normalization gap (#230 finding 2): if editing shuffleScheduleItems could change under a dirty // normalization gap (#230 finding 2): if editing shuffleScheduleItems could change under a dirty
@@ -562,6 +590,16 @@ export function SchedulesScreen() {
onConfirm={onDeleteSchedule} onConfirm={onDeleteSchedule}
onCancel={() => setConfirmDelete(false)} onCancel={() => setConfirmDelete(false)}
/> />
<ConfirmDialog
open={conflictOpen}
title="Schedule changed elsewhere"
message="This schedule was changed elsewhere since you opened it. Reload to get the latest version — your unsaved changes will be discarded."
confirmLabel="Reload"
cancelLabel="Keep editing"
onConfirm={reloadAfterConflict}
onCancel={() => setConflictOpen(false)}
/>
</div> </div>
); );
} }