Two integration tests that drive the entire RFC D17 lifecycle through
the mutation composable + TanStack cache:
1. happy-path lifecycle (5 stages):
- ADD → POST /performances + Idempotency-Key
- DRAG → POST /timetable/move (target_lane=1, version bumps),
server returns cascaded[] sibling — both surface in
the resolved Promise
- RESIZE → POST /timetable/move with new end_at + new version
- PARK → POST /timetable/move with target_stage_id=null
- DELETE → DELETE /performances/p1
final wire: 4 POSTs + 1 DELETE
2. drag rollback on 409:
- server returns version_mismatch
- mutation rejects with VersionMismatchError shape
- notification.show() invoked with the Dutch toast + 'error'
Why not the full page mount: events/[id]/timetable/index.vue requires
EventTabsNav, useEventDetail, useEventChildren, multiple VTabs/VBtn/
VDialog teleports — too brittle for jsdom CI. The end-to-end + visual
flavour of this flow lives on TEST-INFRA-001's Playwright migration
backlog (and TEST-CONTRACT-001 covers the 409 path against a real
backend).
Test count: 383 → 385.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
8.2 KiB
TypeScript
255 lines
8.2 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'
|
|
|
|
/**
|
|
* 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<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 {
|
|
// 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<typeof useTimetableMutations>
|
|
}
|
|
|
|
function mountFlow() {
|
|
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('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')
|
|
})
|
|
})
|