RFC-TIMETABLE v0.2 Session 4 — Frontend Timetable + Test Coverage Closure #18
254
apps/app/tests/integration/timetable-flow.test.ts
Normal file
254
apps/app/tests/integration/timetable-flow.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user