fix(17): WR-02 keep one reorder save in flight and ignore stale responses

A move made while a save runs is queued and sent after it settles, so
out-of-order completion can no longer leave the database on an older tree
than the screen. A failure that a waiting save supersedes keeps the later
moves, and a response tied to an earlier load or partition leaves the
current tree and its last saved state alone.
This commit is contained in:
2026-10-12 21:32:56 +02:00
parent 964f1a7f11
commit e1ac983f1c
3 changed files with 160 additions and 17 deletions

View File

@@ -318,6 +318,94 @@ describe('ReorderView moves', () => {
})
})
// WR-02: one save in flight at a time, later moves sent after it settles,
// and a failure that a waiting save supersedes does not throw moves away.
describe('ReorderView save ordering', () => {
it('sends a move made during a save after that save, with the latest tree', async () => {
const bodies: { nodes: { id: number }[] }[] = []
const releases: ((reply: Reply) => void)[] = []
const { wrapper, calls } = await mountApp(
'/acme/companions/links/reorder',
{
[`GET ${REORDER}`]: tree(),
[`POST ${REORDER}`]: async (request) => {
bodies.push((await request.json()) as { nodes: { id: number }[] })
return new Promise<Reply>((resolve) => releases.push(resolve))
},
},
{ attach: true },
)
keydown(handle(wrapper, 1).element, 'ArrowDown')
await flushPromises()
await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1))
// A second move while the first save is in flight waits for it.
keydown(handle(wrapper, 4).element, 'ArrowUp')
await flushPromises()
await wait(500)
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
// The first save fails: the later moves stay on screen, and the waiting
// save sends them.
releases[0]!({ status: 500, body: { error: { code: 'x', message: 'x', details: {} } } })
await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(2))
expect(shape(wrapper)).toBe('Blog(News) Bez tytułu (#4) Home')
expect(toasts.value).toHaveLength(0)
expect(bodies[1]!.nodes.map((node) => node.id)).toEqual([2, 3, 4, 1])
releases[1]!(tree())
await vi.waitFor(() => {
expect(wrapper.find('[data-reorder-status]').text()).toBe('Zapisano kolejność')
})
expect(shape(wrapper)).toBe('Blog(News) Bez tytułu (#4) Home')
await wait(500)
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(2)
})
it('ignores a save response that arrives after the partition changed', async () => {
let releaseFirst: (reply: Reply) => void = () => undefined
let posts = 0
const footer = tree()
;(footer.body as { data: { nodes: unknown[] } }).data.nodes = [
{ id: 7, name: 'Imprint', parent_id: null, sort_order: 1 },
{ id: 8, name: 'Privacy', parent_id: null, sort_order: 2 },
]
const { wrapper } = await mountApp(
'/acme/companions/links/reorder',
{
[`GET ${REORDER}`]: (request) => (queryOf(request).get('partition') === '6' ? footer : tree()),
[`POST ${REORDER}`]: () => {
posts++
if (posts === 1) {
return new Promise<Reply>((resolve) => {
releaseFirst = resolve
})
}
return { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }
},
},
{ attach: true },
)
keydown(handle(wrapper, 1).element, 'ArrowDown')
await flushPromises()
await wrapper.find('[data-reorder-partition]').setValue('6')
await flushPromises()
await vi.waitFor(() => expect(shape(wrapper)).toBe('Imprint Privacy'))
// The first partition's save succeeds late: the second partition's
// screen and its last saved tree are not touched.
releaseFirst(tree())
await flushPromises()
expect(shape(wrapper)).toBe('Imprint Privacy')
// A failed save on the second partition restores its own tree.
keydown(handle(wrapper, 7).element, 'ArrowDown')
await flushPromises()
expect(shape(wrapper)).toBe('Privacy Imprint')
await vi.waitFor(() => expect(toasts.value.length).toBeGreaterThan(0))
expect(shape(wrapper)).toBe('Imprint Privacy')
})
})
describe('reorder tree rules', () => {
// The OpenAPI document types parent_id as a number although the wire
// carries null at the top (17-02 deferred item 3), hence the cast.