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

@@ -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)
})
})