diff --git a/apps/app/tests/integration/timetable-flow.test.ts b/apps/app/tests/integration/timetable-flow.test.ts new file mode 100644 index 00000000..1d2b6113 --- /dev/null +++ b/apps/app/tests/integration/timetable-flow.test.ts @@ -0,0 +1,254 @@ +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') + }) +})