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' 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 p(overrides: Partial = {}): Performance { return { id: 'p1', engagement_id: 'e1', event_id: 'ev1', 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: null, updated_at: null, deleted_at: null, ...overrides, } } function mountWithMutations() { const api: { value: ReturnType | null } = { value: null } const orgId = ref('org_1') const eventId = ref('ev_1') const dayId = ref('day_1') const Host = defineComponent({ setup() { api.value = useTimetableMutations({ orgId, eventId, dayId }) return () => h('div') }, }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) const pinia = createPinia() const wrapper = mount(Host, { global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] }, }) return { wrapper, api, queryClient } } describe('useTimetableMutations', () => { beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() }) afterEach(() => { vi.clearAllMocks() }) describe('move', () => { it('sends Idempotency-Key header on POST /timetable/move', async () => { mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: p({ version: 4 }), cascaded: [] } } }) const { api } = mountWithMutations() await api.value!.move.mutateAsync({ payload: { performance_id: 'p1', target_stage_id: 's1', target_start_at: '2026-07-10 19:00:00', target_end_at: '2026-07-10 20:00:00', target_lane: 0, version: 3, }, idempotencyKey: 'idem-test-key-12345', }) expect(mocked.post).toHaveBeenCalledTimes(1) const [, , config] = mocked.post.mock.calls[0] expect(config.headers['Idempotency-Key']).toBe('idem-test-key-12345') }) it('applies optimistic patch + cascade on success', async () => { mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: p({ id: 'p1', version: 4 }), cascaded: [p({ id: 'p2', lane: 1, lane_resolved: 1, version: 4 })], }, }, }) const { api, queryClient } = mountWithMutations() const eventId = ref('ev_1') const dayId = ref('day_1') queryClient.setQueryData(['timetable', 'performances', eventId, dayId], [ p({ id: 'p1' }), p({ id: 'p2', lane: 0, lane_resolved: 0 }), ]) const result = await api.value!.move.mutateAsync({ payload: { performance_id: 'p1', target_stage_id: 's1', target_start_at: '2026-07-10 19:00:00', target_end_at: '2026-07-10 20:00:00', target_lane: 0, version: 3, }, idempotencyKey: 'idem', }) expect(result.cascaded).toHaveLength(1) expect(result.moved.version).toBe(4) }) it('surfaces VersionMismatch on 409', async () => { mocked.post.mockRejectedValueOnce({ response: { status: 409, data: { errors: { conflict: 'version_mismatch', current_version: 5, client_version: 3, server_data: p({ version: 5 }), }, }, }, }) const { api } = mountWithMutations() await expect(api.value!.move.mutateAsync({ payload: { performance_id: 'p1', target_stage_id: 's1', target_start_at: '2026-07-10 19:00:00', target_end_at: '2026-07-10 20:00:00', target_lane: 0, version: 3, }, idempotencyKey: 'idem', })).rejects.toMatchObject({ status: 409, conflict: { conflict: 'version_mismatch', current_version: 5 } }) }) }) describe('park / unpark via move', () => { it('park sends target_stage_id null', async () => { mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: p({ stage_id: null, version: 4 }), cascaded: [] } }, }) const { api } = mountWithMutations() await api.value!.park(p(), 'key1') const [, body] = mocked.post.mock.calls[0] expect(body.target_stage_id).toBe(null) expect(body.version).toBe(3) }) }) describe('createStage', () => { it('hits POST /stages', async () => { mocked.post.mockResolvedValueOnce({ data: { success: true, data: { id: 's2', name: 'New Stage', color: '#aabbcc', capacity: 1000, sort_order: 1, event_id: 'ev_1', created_at: '2026-07-10T18:00:00.000Z', updated_at: '2026-07-10T18:00:00.000Z', }, }, }) const { api } = mountWithMutations() await api.value!.createStage.mutateAsync({ name: 'New Stage', color: '#aabbcc', capacity: 1000 }) expect(mocked.post.mock.calls[0][0]).toContain('/stages') }) }) })