Per Phase A finding A5 — Zod schemas in @/schemas/timetable.ts were
types-only; nothing parsed actual server responses. Backend → frontend
contract drift would only surface as TypeError deep in components.
useTimetable.ts queries now parse:
- useStages → stageArraySchema.parse()
- usePerformances → performanceArraySchema.parse()
- useWachtrij → performanceArraySchema.parse()
- useEngagement → artistEngagementSchema.parse()
useTimetableMutations.ts mutations now parse:
- move success → moveTimetableSuccessSchema.parse()
- move 409 errors → moveTimetableConflictSchema.parse() (the .errors
sub-object — see backend canon at TimetableMoveController:64)
- create / updateNotes → performanceSchema.parse()
- createStage / updateStage → stageSchema.parse()
The move() success parse runs OUTSIDE the try/catch so a Zod failure on
a 200 response surfaces as a true error rather than being misclassified
as a 409. Per Phase A finding A8 the conflict shape already matches
backend field-for-field; no schema correction needed, but the parse()
locks future drift in.
Regression test (tests/unit/composables/api/zodParseFailure.test.ts):
- move() success with missing fields → rejects with ZodError
- move() 409 with malformed errors payload → rejects with ZodError
- createStage() with missing fields → rejects with ZodError
Existing test fixture for createStage was missing created_at/updated_at;
fixed in same commit (real backend responses always include them).
Test count: 321 → 324.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
5.8 KiB
TypeScript
214 lines
5.8 KiB
TypeScript
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
|
import { mount } from '@vue/test-utils'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { defineComponent, h, ref } from 'vue'
|
|
import { apiClient } from '@/lib/axios'
|
|
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
|
|
import type { Performance } from '@/types/timetable'
|
|
|
|
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 p(overrides: Partial<Performance> = {}): Performance {
|
|
return {
|
|
id: 'p1',
|
|
engagement_id: 'e1',
|
|
event_id: 'ev1',
|
|
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: 3,
|
|
notes: null,
|
|
warnings: [],
|
|
created_at: null,
|
|
updated_at: null,
|
|
deleted_at: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function mountWithMutations() {
|
|
const api: { value: ReturnType<typeof useTimetableMutations> | null } = { value: null }
|
|
const orgId = ref('org_1')
|
|
const eventId = ref('ev_1')
|
|
const dayId = ref<string | null>('day_1')
|
|
|
|
const Host = defineComponent({
|
|
setup() {
|
|
api.value = useTimetableMutations({ orgId, eventId, dayId })
|
|
|
|
return () => h('div')
|
|
},
|
|
})
|
|
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
|
|
|
const wrapper = mount(Host, {
|
|
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
|
|
})
|
|
|
|
return { wrapper, api, queryClient }
|
|
}
|
|
|
|
describe('useTimetableMutations', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('move', () => {
|
|
it('sends Idempotency-Key header on POST /timetable/move', async () => {
|
|
mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: p({ version: 4 }), cascaded: [] } } })
|
|
|
|
const { api } = mountWithMutations()
|
|
|
|
await api.value!.move.mutateAsync({
|
|
payload: {
|
|
performance_id: 'p1',
|
|
target_stage_id: 's1',
|
|
target_start_at: '2026-07-10 19:00:00',
|
|
target_end_at: '2026-07-10 20:00:00',
|
|
target_lane: 0,
|
|
version: 3,
|
|
},
|
|
idempotencyKey: 'idem-test-key-12345',
|
|
})
|
|
|
|
expect(mocked.post).toHaveBeenCalledTimes(1)
|
|
|
|
const [, , config] = mocked.post.mock.calls[0]
|
|
|
|
expect(config.headers['Idempotency-Key']).toBe('idem-test-key-12345')
|
|
})
|
|
|
|
it('applies optimistic patch + cascade on success', async () => {
|
|
mocked.post.mockResolvedValueOnce({
|
|
data: {
|
|
success: true,
|
|
data: {
|
|
moved: p({ id: 'p1', version: 4 }),
|
|
cascaded: [p({ id: 'p2', lane: 1, lane_resolved: 1, version: 4 })],
|
|
},
|
|
},
|
|
})
|
|
|
|
const { api, queryClient } = mountWithMutations()
|
|
const eventId = ref('ev_1')
|
|
const dayId = ref<string | null>('day_1')
|
|
|
|
queryClient.setQueryData(['timetable', 'performances', eventId, dayId], [
|
|
p({ id: 'p1' }),
|
|
p({ id: 'p2', lane: 0, lane_resolved: 0 }),
|
|
])
|
|
|
|
const result = await api.value!.move.mutateAsync({
|
|
payload: {
|
|
performance_id: 'p1',
|
|
target_stage_id: 's1',
|
|
target_start_at: '2026-07-10 19:00:00',
|
|
target_end_at: '2026-07-10 20:00:00',
|
|
target_lane: 0,
|
|
version: 3,
|
|
},
|
|
idempotencyKey: 'idem',
|
|
})
|
|
|
|
expect(result.cascaded).toHaveLength(1)
|
|
expect(result.moved.version).toBe(4)
|
|
})
|
|
|
|
it('surfaces VersionMismatch on 409', async () => {
|
|
mocked.post.mockRejectedValueOnce({
|
|
response: {
|
|
status: 409,
|
|
data: {
|
|
errors: {
|
|
conflict: 'version_mismatch',
|
|
current_version: 5,
|
|
client_version: 3,
|
|
server_data: p({ version: 5 }),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const { api } = mountWithMutations()
|
|
|
|
await expect(api.value!.move.mutateAsync({
|
|
payload: {
|
|
performance_id: 'p1',
|
|
target_stage_id: 's1',
|
|
target_start_at: '2026-07-10 19:00:00',
|
|
target_end_at: '2026-07-10 20:00:00',
|
|
target_lane: 0,
|
|
version: 3,
|
|
},
|
|
idempotencyKey: 'idem',
|
|
})).rejects.toMatchObject({ status: 409, conflict: { conflict: 'version_mismatch', current_version: 5 } })
|
|
})
|
|
})
|
|
|
|
describe('park / unpark via move', () => {
|
|
it('park sends target_stage_id null', async () => {
|
|
mocked.post.mockResolvedValueOnce({
|
|
data: { success: true, data: { moved: p({ stage_id: null, version: 4 }), cascaded: [] } },
|
|
})
|
|
|
|
const { api } = mountWithMutations()
|
|
|
|
await api.value!.park(p(), 'key1')
|
|
|
|
const [, body] = mocked.post.mock.calls[0]
|
|
|
|
expect(body.target_stage_id).toBe(null)
|
|
expect(body.version).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('createStage', () => {
|
|
it('hits POST /stages', async () => {
|
|
mocked.post.mockResolvedValueOnce({
|
|
data: {
|
|
success: true,
|
|
data: {
|
|
id: 's2',
|
|
name: 'New Stage',
|
|
color: '#aabbcc',
|
|
capacity: 1000,
|
|
sort_order: 1,
|
|
event_id: 'ev_1',
|
|
created_at: '2026-07-10T18:00:00.000Z',
|
|
updated_at: '2026-07-10T18:00:00.000Z',
|
|
},
|
|
},
|
|
})
|
|
|
|
const { api } = mountWithMutations()
|
|
|
|
await api.value!.createStage.mutateAsync({ name: 'New Stage', color: '#aabbcc', capacity: 1000 })
|
|
|
|
expect(mocked.post.mock.calls[0][0]).toContain('/stages')
|
|
})
|
|
})
|
|
})
|