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>())
|
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>(
|
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>(
|
||||||
[MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7))]));
|
[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(
|
IActionResult result = await _controller.Replace(
|
||||||
4,
|
4,
|
||||||
@@ -359,6 +361,8 @@ public class DecoTemplateControllerTests
|
|||||||
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning", version: 4)));
|
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning", version: 4)));
|
||||||
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
|
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
|
||||||
|
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<DecoTemplateItemViewModel>());
|
||||||
|
|
||||||
IActionResult result = await _controller.Replace(
|
IActionResult result = await _controller.Replace(
|
||||||
4,
|
4,
|
||||||
@@ -380,6 +384,8 @@ public class DecoTemplateControllerTests
|
|||||||
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning")));
|
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning")));
|
||||||
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
|
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
|
||||||
|
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<DecoTemplateItemViewModel>());
|
||||||
|
|
||||||
await _controller.Replace(
|
await _controller.Replace(
|
||||||
4,
|
4,
|
||||||
|
|||||||
@@ -355,6 +355,22 @@ public class PlaylistControllerTests
|
|||||||
false,
|
false,
|
||||||
true)
|
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(
|
IActionResult result = await _controller.Update(
|
||||||
4,
|
4,
|
||||||
@@ -407,6 +423,8 @@ public class PlaylistControllerTests
|
|||||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 3)));
|
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 3)));
|
||||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>()));
|
.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(
|
IActionResult result = await _controller.Update(
|
||||||
4,
|
4,
|
||||||
@@ -426,6 +444,8 @@ public class PlaylistControllerTests
|
|||||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>()));
|
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>()));
|
||||||
|
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<PlaylistItemViewModel>());
|
||||||
|
|
||||||
await _controller.Update(
|
await _controller.Update(
|
||||||
4,
|
4,
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ public class ScheduleControllerTests
|
|||||||
List<ProgramScheduleItemViewModel> items = [MakeOneItem(21), MakeOneItem(22)];
|
List<ProgramScheduleItemViewModel> items = [MakeOneItem(21), MakeOneItem(22)];
|
||||||
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(items));
|
.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(
|
IActionResult result = await _controller.ReplaceItems(
|
||||||
4,
|
4,
|
||||||
@@ -313,6 +317,8 @@ public class ScheduleControllerTests
|
|||||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
|
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
|
||||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 4)));
|
.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(
|
IActionResult result = await _controller.ReplaceItems(
|
||||||
4,
|
4,
|
||||||
@@ -334,6 +340,8 @@ public class ScheduleControllerTests
|
|||||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
|
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
|
||||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 1)));
|
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 1)));
|
||||||
|
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns([MakeOneItem(21)]);
|
||||||
|
|
||||||
await _controller.ReplaceItems(
|
await _controller.ReplaceItems(
|
||||||
4,
|
4,
|
||||||
|
|||||||
@@ -257,6 +257,8 @@ public class TemplateControllerTests
|
|||||||
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, List<TemplateItemViewModel>>(
|
.Returns(Right<BaseError, List<TemplateItemViewModel>>(
|
||||||
[MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60)]));
|
[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(
|
IActionResult result = await _controller.Replace(
|
||||||
4,
|
4,
|
||||||
@@ -346,6 +348,8 @@ public class TemplateControllerTests
|
|||||||
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning", version: 4)));
|
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning", version: 4)));
|
||||||
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
|
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
|
||||||
|
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<TemplateItemViewModel>());
|
||||||
|
|
||||||
IActionResult result = await _controller.Replace(
|
IActionResult result = await _controller.Replace(
|
||||||
4,
|
4,
|
||||||
@@ -367,6 +371,8 @@ public class TemplateControllerTests
|
|||||||
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning")));
|
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning")));
|
||||||
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
|
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
|
||||||
|
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<TemplateItemViewModel>());
|
||||||
|
|
||||||
await _controller.Replace(
|
await _controller.Replace(
|
||||||
4,
|
4,
|
||||||
|
|||||||
@@ -212,10 +212,14 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
|||||||
|
|
||||||
return await result.Match(
|
return await result.Match(
|
||||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
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 =
|
Option<DecoTemplateViewModel> refreshed =
|
||||||
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
|
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
|
||||||
|
List<DecoTemplateItemViewModel> items =
|
||||||
|
await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
|
||||||
return refreshed.Match(
|
return refreshed.Match(
|
||||||
Some: vm =>
|
Some: vm =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -222,12 +222,21 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
|||||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||||
return await result.Match(
|
return await result.Match(
|
||||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
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 =
|
Option<PlaylistViewModel> refreshed =
|
||||||
await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
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 (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
|
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(
|
return await result.Match(
|
||||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
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 =
|
Option<ProgramScheduleViewModel> refreshed =
|
||||||
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
|
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 (IActionResult)new OkObjectResult(
|
return refreshed.Match(
|
||||||
items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList());
|
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(
|
return await result.Match(
|
||||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
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);
|
Option<TemplateViewModel> refreshed = await mediator.Send(new GetTemplateById(id), cancellationToken);
|
||||||
|
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
|
||||||
return refreshed.Match(
|
return refreshed.Match(
|
||||||
Some: vm =>
|
Some: vm =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -391,8 +391,14 @@ function PlaylistEditor({ playlistId, onBack, onSaved }: { playlistId: number; o
|
|||||||
activeRef.current = true;
|
activeRef.current = true;
|
||||||
// Read items + ETag FIRST, then the root metadata, so the ETag is never newer than the data
|
// 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
|
// the draft is built from (issue #253) — any resulting inconsistency fails safe via a 412 on
|
||||||
// save rather than a silent overwrite.
|
// save rather than a silent overwrite. These MUST be sequential, not Promise.all: a concurrent
|
||||||
Promise.all([getPlaylistItemsWithMeta(playlistId), getPlaylistById(playlistId)])
|
// 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]) => {
|
.then(([itemsMeta, playlist]) => {
|
||||||
if (!activeRef.current) {
|
if (!activeRef.current) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ 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;
|
||||||
|
// 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)
|
getScheduleItemsWithMeta(scheduleId)
|
||||||
.then(({ data: response, etag }) => {
|
.then(({ data: response, etag }) => {
|
||||||
if (!activeRef.current || itemSeq.current !== seq) {
|
if (!activeRef.current || itemSeq.current !== seq) {
|
||||||
|
|||||||
Reference in New Issue
Block a user