feat(timetable): validate API responses against Zod schemas at runtime

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>
This commit is contained in:
2026-05-09 03:32:21 +02:00
parent 5f135ec2b9
commit 8d1cb39172
4 changed files with 176 additions and 13 deletions

View File

@@ -0,0 +1,128 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { ZodError } from 'zod'
import { apiClient } from '@/lib/axios'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
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 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 } } })
mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] } })
return { api }
}
describe('Zod parse failure on API responses', () => {
beforeEach(() => vi.clearAllMocks())
it('move() throws a ZodError when the success payload omits required fields', async () => {
// Backend renamed `cascaded` → `cascadedItems`, or removed `version`, etc.
// Whatever the drift, our Zod schema must reject it loudly so GlitchTip
// sees a contract violation instead of silently coercing into runtime
// crashes deep in components that read `.lane_resolved`.
mocked.post.mockResolvedValueOnce({
data: {
success: true,
data: {
moved: { id: 'p1' /* missing nearly every required field */ },
cascaded: [],
},
},
})
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-test',
})).rejects.toBeInstanceOf(ZodError)
})
it('move() 409 with malformed errors payload also throws a ZodError', async () => {
// The 409 path parses err.response.data.errors against
// moveTimetableConflictSchema. A drift in the conflict shape (e.g.
// backend renames `current_version` → `currentVersion`) must surface as
// a ZodError, not as a "missing field" ReferenceError downstream.
mocked.post.mockRejectedValueOnce({
response: {
status: 409,
data: {
errors: {
conflict: 'version_mismatch',
// current_version + client_version + server_data are missing
},
},
},
})
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-test',
})).rejects.toBeInstanceOf(ZodError)
})
it('createStage() throws a ZodError when response is malformed', async () => {
mocked.post.mockResolvedValueOnce({
data: {
success: true,
data: { id: 's2', name: 'X' /* missing color, sort_order, etc. */ },
},
})
const { api } = mountWithMutations()
await expect(
api.value!.createStage.mutateAsync({ name: 'X', color: '#aabbcc' }),
).rejects.toBeInstanceOf(ZodError)
})
})