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 put: ReturnType get: ReturnType delete: ReturnType } const mocked = apiClient as unknown as MockApi function perf(overrides: Partial = {}): 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 } function mountMutations() { const Host = defineComponent({ setup(_, { expose }) { const api = useTimetableMutations({ orgId: ref('org_1'), eventId: ref('ev_1'), dayId: ref('day_1'), }) expose({ api }) return () => h('div') }, }) return mountWithVuexy(Host) } function getApi(wrapper: { vm: object }): ReturnType { 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) }) })