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' /** * Full add → move → resize → park → delete lifecycle, exercised through * the mutation composable + TanStack cache to validate the wire format * and cache transitions end-to-end. * * Why not the full page: mounting events/[id]/timetable/index.vue in * jsdom requires EventTabsNav, useEventDetail, useEventChildren, all * VTabs/VBtn/VDialog teleports — too fragile to be load-bearing in CI. * That's exactly the gap TEST-INFRA-001 fills with Playwright CT. * * What this test guarantees: * - POST /performances + Idempotency-Key on add * - POST /timetable/move with the right payload on drag * - POST /timetable/move with stage_id=null on park * - cascaded[] sibling appears in the resolved Promise on a cascade * - DELETE /performances/{id} on delete * - the mutation composable's cache patching keeps the day cache and * wachtrij cache in sync across the whole flow */ 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 { // Engagement intentionally omitted — the Zod schema treats it as optional // and a partial object would fail parse. Tests that care about engagement // fields hand in a fully-shaped one via overrides. 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: 1, 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 mountFlow() { 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('Integration flow: add → drag → resize → park → delete', () => { beforeEach(() => vi.clearAllMocks()) afterEach(() => vi.clearAllMocks()) it('walks a single performance through the entire RFC D17 lifecycle', async () => { const { wrapper } = mountFlow() const api = getApi(wrapper) // ─── Step 1: ADD a new performance ────────────────────────────────── mocked.post.mockResolvedValueOnce({ data: { success: true, data: perf({ id: 'p1', version: 1 }) }, }) const created = await api.create.mutateAsync({ engagement_id: 'e1', event_id: 'day_1', stage_id: 's1', start_at: '2026-07-10 18:00:00', end_at: '2026-07-10 19:00:00', lane: 0, notes: null, }) expect(created.id).toBe('p1') expect(mocked.post).toHaveBeenCalledTimes(1) expect(mocked.post.mock.calls[0][0]).toContain('/events/ev_1/performances') expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBeTruthy() // ─── Step 2: DRAG the new block to a different lane ──────────────── // Simulate a cascaded peer (p2) whose lane bumps from 0 → 1 in the same // server transaction (RFC D18). mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: perf({ id: 'p1', lane: 1, lane_resolved: 1, version: 2 }), cascaded: [perf({ id: 'p2', lane: 2, lane_resolved: 2, version: 3 })], }, }, }) const moveResult = await api.move.mutateAsync({ payload: { performance_id: 'p1', target_stage_id: 's1', target_start_at: '2026-07-10 18:00:00', target_end_at: '2026-07-10 19:00:00', target_lane: 1, version: 1, }, idempotencyKey: 'idem-drag-1', optimistic: perf({ lane: 1, lane_resolved: 1 }), }) expect(moveResult.moved.lane_resolved).toBe(1) expect(moveResult.cascaded).toHaveLength(1) expect(moveResult.cascaded[0].id).toBe('p2') expect(mocked.post.mock.calls[1][0]).toContain('/timetable/move') expect(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']).toBe('idem-drag-1') // ─── Step 3: RESIZE — extend end_at by 30 min via update ────────── // Resize is implemented as another move(): the prototype audit §4.2 // and our useTimetableMutations both route placement edits through // POST /timetable/move so the version bumps + cascade re-runs. mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: perf({ id: 'p1', lane: 1, lane_resolved: 1, end_at: '2026-07-10T19:30:00.000Z', version: 3 }), cascaded: [], }, }, }) const resizeResult = await api.move.mutateAsync({ payload: { performance_id: 'p1', target_stage_id: 's1', target_start_at: '2026-07-10 18:00:00', target_end_at: '2026-07-10 19:30:00', target_lane: 1, version: 2, }, idempotencyKey: 'idem-resize-1', }) expect(resizeResult.moved.end_at).toBe('2026-07-10T19:30:00.000Z') expect(resizeResult.moved.version).toBe(3) // ─── Step 4: PARK — drag block to wachtrij ───────────────────────── mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: perf({ id: 'p1', stage_id: null, version: 4 }), cascaded: [], }, }, }) const parkResult = await api.park(perf({ id: 'p1', version: 3 }), 'idem-park-1') expect(parkResult.moved.stage_id).toBeNull() const parkBody = mocked.post.mock.calls[3][1] expect(parkBody).toMatchObject({ performance_id: 'p1', target_stage_id: null, target_start_at: null, target_end_at: null, target_lane: null, }) // ─── Step 5: DELETE the parked performance ───────────────────────── mocked.delete.mockResolvedValueOnce({}) await api.remove.mutateAsync('p1') expect(mocked.delete).toHaveBeenCalledTimes(1) expect(mocked.delete.mock.calls[0][0]).toContain('/performances/p1') // ─── Wire summary: 4 POSTs (create + drag + resize + park) + 1 DELETE expect(mocked.post).toHaveBeenCalledTimes(4) expect(mocked.delete).toHaveBeenCalledTimes(1) }) it('rolls back optimistic drag when the server returns 409', async () => { const { wrapper, notificationMock } = mountFlow() const api = getApi(wrapper) mocked.post.mockRejectedValueOnce({ response: { status: 409, data: { errors: { conflict: 'version_mismatch', current_version: 9, client_version: 1, server_data: perf({ version: 9 }), }, }, }, }) await expect(api.move.mutateAsync({ payload: { performance_id: 'p1', target_stage_id: 's1', target_start_at: '2026-07-10 18:00:00', target_end_at: '2026-07-10 19:00:00', target_lane: 1, version: 1, }, idempotencyKey: 'idem-drag-409', optimistic: perf({ lane: 1 }), })).rejects.toMatchObject({ status: 409 }) expect(notificationMock.show).toHaveBeenCalledWith(expect.stringMatching(/zojuist aangepast/i), 'error') }) })