RFC-TIMETABLE v0.2 Session 4 — Frontend Timetable + Test Coverage Closure #18

Merged
bert.hausmans merged 26 commits from feat/timetable-session-4 into main 2026-05-10 00:32:37 +02:00
Showing only changes of commit 985a5ab987 - Show all commits

View File

@@ -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<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')
})
})