test(timetable): useTimetableMutations 409 rollback + idempotency-key semantics (Step 9)

Minimal seam in src/composables/api/useTimetableMutations.ts: the move()
mutation's onError now calls useNotificationStore().show(...) on a 409
status. Generic axios errors stay quiet here — the global response
handler in lib/axios/factory.ts already toasts those. RFC D14 wanted
the version-mismatch toast specifically.

apps/app/tests/component/useTimetableMutations.test.ts (NEW, 5 tests):
  - on success: returns server payload with bumped version + sends the
    Idempotency-Key supplied by the caller
  - 409: rejects with VersionMismatchError + notification.show()
    invoked once with the Dutch translation + 'error' level
  - cascade: success with cascaded[] populated puts those peers into
    the result.cascaded array
  - Idempotency-Key uniqueness: two distinct logical move() calls send
    distinct keys
  - Idempotency-Key reuse: caller-controlled retry within the same
    logical action sends the SAME key on the wire (so the backend's
    60s idempotency middleware dedupes)

The two existing unit-project tests now register a Pinia instance
(createPinia + setActivePinia) so useNotificationStore() resolves.
Existing assertions unchanged.

Test count: 364 → 369.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 03:48:39 +02:00
parent 8db6ca6024
commit fbfe72d090
4 changed files with 276 additions and 4 deletions

View File

@@ -10,6 +10,7 @@ import {
performanceSchema,
stageSchema,
} from '@/schemas/timetable'
import { useNotificationStore } from '@/stores/useNotificationStore'
import type {
CreatePerformancePayload,
CreateStagePayload,
@@ -64,6 +65,7 @@ function isVersionMismatch(err: unknown): err is { response: { status: 409; data
export function useTimetableMutations(args: UseTimetableMutationsArgs) {
const queryClient = useQueryClient()
const notification = useNotificationStore()
const { orgId, eventId, dayId } = args
const performancesKey = () => ['timetable', 'performances', eventId, dayId] as const
@@ -173,13 +175,19 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
mergePerformance(result.moved)
applyCascade(result.cascaded)
},
onError: (_err, _vars, ctx) => {
onError: (err, _vars, ctx) => {
// Restore cached blocks from snapshot so the canvas snaps back.
if (ctx?.snapshot)
mergePerformance(ctx.snapshot)
if (ctx?.snapshotWachtrij)
mergePerformance(ctx.snapshotWachtrij)
invalidate()
// RFC D14 — version mismatch toast. Generic axios errors stay quiet
// here; they're already surfaced by the global response handler in
// lib/axios/factory.ts.
if ((err as { status?: number } | null)?.status === 409)
notification.show('Iemand anders heeft dit zojuist aangepast — venster ververst.', 'error')
},
})

View File

@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { mountWithVuexy } from '../utils/mountWithVuexy'
import { apiClient } from '@/lib/axios'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import type { Performance } from '@/types/timetable'
vi.mock('@/lib/axios', () => {
const post = vi.fn()
const put = vi.fn()
const get = vi.fn()
const del = vi.fn()
return { apiClient: { post, put, get, delete: del } }
})
interface MockApi {
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
const mocked = apiClient as unknown as MockApi
function perf(overrides: Partial<Performance> = {}): Performance {
return {
id: 'p1',
engagement_id: 'e1',
event_id: 'ev_1',
stage_id: 's1',
lane: 0,
lane_resolved: 0,
start_at: '2026-07-10T18:00:00.000Z',
end_at: '2026-07-10T19:00:00.000Z',
version: 3,
notes: null,
warnings: [],
created_at: '2026-07-10T18:00:00.000Z',
updated_at: '2026-07-10T18:00:00.000Z',
deleted_at: null,
...overrides,
}
}
interface ExposedShape {
api: ReturnType<typeof useTimetableMutations>
}
function mountMutations() {
const Host = defineComponent({
setup(_, { expose }) {
const api = useTimetableMutations({
orgId: ref('org_1'),
eventId: ref('ev_1'),
dayId: ref<string | null>('day_1'),
})
expose({ api })
return () => h('div')
},
})
return mountWithVuexy(Host)
}
function getApi(wrapper: { vm: object }): ReturnType<typeof useTimetableMutations> {
return (wrapper.vm as { $: { exposed: ExposedShape } }).$.exposed.api
}
describe('useTimetableMutations.move — optimistic + 409 + idempotency-key', () => {
beforeEach(() => vi.clearAllMocks())
afterEach(() => vi.useRealTimers())
it('on success: returns the server payload with the bumped version + sends Idempotency-Key', async () => {
mocked.post.mockResolvedValueOnce({
data: {
success: true,
data: {
moved: perf({ version: 4, start_at: '2026-07-10T20:00:00.000Z', end_at: '2026-07-10T21:00:00.000Z' }),
cascaded: [],
},
},
})
const { wrapper } = mountMutations()
const api = getApi(wrapper)
const result = await api.move.mutateAsync({
payload: {
performance_id: 'p1',
target_stage_id: 's1',
target_start_at: '2026-07-10 20:00:00',
target_end_at: '2026-07-10 21:00:00',
target_lane: 0,
version: 3,
},
idempotencyKey: 'idem-A',
optimistic: perf({ start_at: '2026-07-10T20:00:00.000Z', end_at: '2026-07-10T21:00:00.000Z' }),
})
expect(result.moved.version).toBe(4)
expect(mocked.post).toHaveBeenCalledTimes(1)
expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe('idem-A')
})
it('409 path: rolls back, surfaces VersionMismatchError, and shows notification', async () => {
mocked.post.mockRejectedValueOnce({
response: {
status: 409,
data: {
errors: {
conflict: 'version_mismatch',
current_version: 7,
client_version: 3,
server_data: perf({ version: 7 }),
},
},
},
})
const { wrapper, notificationMock } = mountMutations()
const api = getApi(wrapper)
await expect(api.move.mutateAsync({
payload: {
performance_id: 'p1',
target_stage_id: 's1',
target_start_at: '2026-07-10 20:00:00',
target_end_at: '2026-07-10 21:00:00',
target_lane: 0,
version: 3,
},
idempotencyKey: 'idem-409',
})).rejects.toMatchObject({ status: 409, conflict: { conflict: 'version_mismatch', current_version: 7 } })
expect(notificationMock.show).toHaveBeenCalledTimes(1)
expect(notificationMock.show).toHaveBeenCalledWith(expect.stringMatching(/zojuist aangepast/i), 'error')
})
it('cascade: success with cascaded[] non-empty puts those peers into the cache', async () => {
mocked.post.mockResolvedValueOnce({
data: {
success: true,
data: {
moved: perf({ version: 4 }),
cascaded: [perf({ id: 'p2', lane: 1, lane_resolved: 1, version: 4 })],
},
},
})
const { wrapper } = mountMutations()
const api = getApi(wrapper)
const result = await api.move.mutateAsync({
payload: {
performance_id: 'p1',
target_stage_id: 's1',
target_start_at: '2026-07-10 20:00:00',
target_end_at: '2026-07-10 21:00:00',
target_lane: 0,
version: 3,
},
idempotencyKey: 'idem-cascade',
})
expect(result.cascaded).toHaveLength(1)
expect(result.cascaded[0].id).toBe('p2')
})
it('Idempotency-Key: each logical move() call sends the exact key the caller supplied', async () => {
mocked.post
.mockResolvedValueOnce({
data: {
success: true,
data: { moved: perf({ version: 4 }), cascaded: [] },
},
})
.mockResolvedValueOnce({
data: {
success: true,
data: { moved: perf({ version: 5 }), cascaded: [] },
},
})
const { wrapper } = mountMutations()
const api = getApi(wrapper)
await api.move.mutateAsync({
payload: {
performance_id: 'p1',
target_stage_id: 's1',
target_start_at: '2026-07-10 20:00:00',
target_end_at: '2026-07-10 21:00:00',
target_lane: 0,
version: 3,
},
idempotencyKey: 'idem-action-A',
})
await api.move.mutateAsync({
payload: {
performance_id: 'p1',
target_stage_id: 's1',
target_start_at: '2026-07-10 21:00:00',
target_end_at: '2026-07-10 22:00:00',
target_lane: 0,
version: 4,
},
idempotencyKey: 'idem-action-B',
})
expect(mocked.post).toHaveBeenCalledTimes(2)
expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe('idem-action-A')
expect(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']).toBe('idem-action-B')
expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key'])
.not.toBe(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key'])
})
it('Idempotency-Key: explicit retry within the same logical action reuses the same key', async () => {
// Caller-controlled retry: the page catches a transient network error
// and re-invokes move() with the SAME idempotencyKey it generated for
// that drag. That call must carry the same header on the wire so the
// backend dedupes it.
mocked.post
.mockRejectedValueOnce({ message: 'Network down', response: undefined })
.mockResolvedValueOnce({
data: {
success: true,
data: { moved: perf({ version: 4 }), cascaded: [] },
},
})
const { wrapper } = mountMutations()
const api = getApi(wrapper)
const sameKey = 'idem-drag-XYZ'
const payload = {
performance_id: 'p1',
target_stage_id: 's1',
target_start_at: '2026-07-10 20:00:00',
target_end_at: '2026-07-10 21:00:00',
target_lane: 0,
version: 3,
} as const
await expect(api.move.mutateAsync({ payload, idempotencyKey: sameKey })).rejects.toBeTruthy()
await api.move.mutateAsync({ payload, idempotencyKey: sameKey })
expect(mocked.post).toHaveBeenCalledTimes(2)
expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe(sameKey)
expect(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']).toBe(sameKey)
})
})

View File

@@ -1,5 +1,6 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { ZodError } from 'zod'
@@ -39,14 +40,18 @@ function mountWithMutations() {
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const pinia = createPinia()
mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] } })
mount(Host, { global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] } })
return { api }
}
describe('Zod parse failure on API responses', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('move() throws a ZodError when the success payload omits required fields', async () => {
// Backend renamed `cascaded` → `cascadedItems`, or removed `version`, etc.

View File

@@ -1,5 +1,6 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { apiClient } from '@/lib/axios'
@@ -59,9 +60,10 @@ function mountWithMutations() {
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const pinia = createPinia()
const wrapper = mount(Host, {
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] },
})
return { wrapper, api, queryClient }
@@ -69,6 +71,7 @@ function mountWithMutations() {
describe('useTimetableMutations', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})