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:
256
apps/app/tests/component/useTimetableMutations.test.ts
Normal file
256
apps/app/tests/component/useTimetableMutations.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user