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>
257 lines
7.5 KiB
TypeScript
257 lines
7.5 KiB
TypeScript
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)
|
|
})
|
|
})
|