fix(#253 PR2): close review findings (ETag/items consistency + SPA load ordering)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent Codex review of #268 found two Blockers the fork missed + two Mediums: - Blocker: replace PUTs returned the handler's item snapshot but re-queried the root for the ETag separately, so a racing writer could pair stale items with a newer ETag (silent overwrite). All four controllers now reload root-then-items (version-first, fail-safe) and 404 when the root is gone between commit and reload — matching the Block reference. Fixes the Blocker + the Medium '200 without ETag' case together. - Blocker: PlaylistsScreen loaded items+root via Promise.all (concurrent), pairing a stale name with the current ETag; now sequential (items-with-meta first, then root). - Medium: SchedulesScreen loadItems now marks not-loaded/loading up front so canEdit is false through the 412 conflict reload (no stale-draft edits lost). Controller unit-test mocks updated to stub the new reload query. Full suite green (ErsatzTV.Tests 1334, web 667, check:api no drift).
This commit is contained in:
@@ -268,6 +268,8 @@ public class DecoTemplateControllerTests
|
||||
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>(
|
||||
[MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7))]));
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7))]);
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
@@ -359,6 +361,8 @@ public class DecoTemplateControllerTests
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning", version: 4)));
|
||||
_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>());
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
@@ -380,6 +384,8 @@ public class DecoTemplateControllerTests
|
||||
.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>());
|
||||
|
||||
await _controller.Replace(
|
||||
4,
|
||||
|
||||
@@ -355,6 +355,22 @@ public class PlaylistControllerTests
|
||||
false,
|
||||
true)
|
||||
}));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>
|
||||
{
|
||||
new(
|
||||
100,
|
||||
0,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new NamedMediaItemViewModel(55, "The Movie"),
|
||||
PlaybackOrder.Shuffle,
|
||||
null,
|
||||
false,
|
||||
true)
|
||||
});
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
@@ -407,6 +423,8 @@ public class PlaylistControllerTests
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 3)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>()));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>());
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
@@ -426,6 +444,8 @@ public class PlaylistControllerTests
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>()));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>());
|
||||
|
||||
await _controller.Update(
|
||||
4,
|
||||
|
||||
@@ -259,6 +259,10 @@ public class ScheduleControllerTests
|
||||
List<ProgramScheduleItemViewModel> items = [MakeOneItem(21), MakeOneItem(22)];
|
||||
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(items));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily")));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(items);
|
||||
|
||||
IActionResult result = await _controller.ReplaceItems(
|
||||
4,
|
||||
@@ -313,6 +317,8 @@ public class ScheduleControllerTests
|
||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 4)));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeOneItem(21)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceItems(
|
||||
4,
|
||||
@@ -334,6 +340,8 @@ public class ScheduleControllerTests
|
||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 1)));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeOneItem(21)]);
|
||||
|
||||
await _controller.ReplaceItems(
|
||||
4,
|
||||
|
||||
@@ -257,6 +257,8 @@ public class TemplateControllerTests
|
||||
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<TemplateItemViewModel>>(
|
||||
[MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60)]));
|
||||
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60)]);
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
@@ -346,6 +348,8 @@ public class TemplateControllerTests
|
||||
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning", version: 4)));
|
||||
_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>());
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
@@ -367,6 +371,8 @@ public class TemplateControllerTests
|
||||
.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>());
|
||||
|
||||
await _controller.Replace(
|
||||
4,
|
||||
|
||||
@@ -212,10 +212,14 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async items =>
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController).
|
||||
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 =>
|
||||
{
|
||||
|
||||
@@ -222,12 +222,21 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async items =>
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). A None root
|
||||
// (deleted between commit and reload) is a 404, never a 200 without an ETag.
|
||||
Option<PlaylistViewModel> refreshed =
|
||||
await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
refreshed.Do(vm => ConcurrencyHeaders.SetETag(Response, vm.Version));
|
||||
List<PlaylistItemViewModel> items = await mediator.Send(new GetPlaylistItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm =>
|
||||
{
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -180,15 +180,24 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async items =>
|
||||
Right: async _ =>
|
||||
{
|
||||
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). A None root
|
||||
// (deleted between commit and reload) is a 404, never a 200 without an ETag.
|
||||
Option<ProgramScheduleViewModel> refreshed =
|
||||
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
|
||||
refreshed.IfSome(vm => ConcurrencyHeaders.SetETag(Response, vm.Version));
|
||||
|
||||
List<ProgramScheduleItemViewModel> items =
|
||||
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
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(
|
||||
items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList());
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -200,9 +200,14 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async items =>
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). Returning the
|
||||
// handler's item snapshot alongside a separately re-queried version could pair stale items
|
||||
// with a newer ETag — a client would then silently overwrite the interleaving write.
|
||||
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 =>
|
||||
{
|
||||
|
||||
@@ -391,8 +391,14 @@ function PlaylistEditor({ playlistId, onBack, onSaved }: { playlistId: number; o
|
||||
activeRef.current = true;
|
||||
// Read items + ETag FIRST, then the root metadata, so the ETag is never newer than the data
|
||||
// the draft is built from (issue #253) — any resulting inconsistency fails safe via a 412 on
|
||||
// save rather than a silent overwrite.
|
||||
Promise.all([getPlaylistItemsWithMeta(playlistId), getPlaylistById(playlistId)])
|
||||
// save rather than a silent overwrite. These MUST be sequential, not Promise.all: a concurrent
|
||||
// root read could return a stale name paired with the current items ETag, and a later save would
|
||||
// then silently restore that stale name.
|
||||
getPlaylistItemsWithMeta(playlistId)
|
||||
.then(async (itemsMeta) => {
|
||||
const playlist = await getPlaylistById(playlistId);
|
||||
return [itemsMeta, playlist] as const;
|
||||
})
|
||||
.then(([itemsMeta, playlist]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
|
||||
@@ -137,6 +137,12 @@ export function SchedulesScreen() {
|
||||
// ---- Load items for the active schedule --------------------------------
|
||||
const loadItems = useCallback((scheduleId: number) => {
|
||||
const seq = ++itemSeq.current;
|
||||
// Mark not-loaded/loading up front so `canEdit` (itemsLoaded && !saving) is false for the whole
|
||||
// (re)load window — including the 412 conflict reload. Otherwise a slow reload GET would leave the
|
||||
// rejected/stale draft editable, and edits made during that window would be silently discarded when
|
||||
// the reload re-seeds the baseline (issue #253 / #242 mutation gating).
|
||||
setItemsLoading(true);
|
||||
setItemsLoaded(false);
|
||||
getScheduleItemsWithMeta(scheduleId)
|
||||
.then(({ data: response, etag }) => {
|
||||
if (!activeRef.current || itemSeq.current !== seq) {
|
||||
|
||||
Reference in New Issue
Block a user