merge: #253 PR2 schedule-items
This commit is contained in:
@@ -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
-3
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+231
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")]
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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' }] });
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user