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

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:
2026-07-11 19:08:25 +02:00
parent b063bc45c0
commit 1a24298105
10 changed files with 92 additions and 13 deletions
@@ -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 =>
{
+11 -2
View File
@@ -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());
});
}
+13 -4
View File
@@ -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 =>
{
+8 -2
View File
@@ -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;
+6
View File
@@ -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) {