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

@@ -1,13 +1,22 @@
import { useQuery } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { computed } from 'vue'
import { z } from 'zod'
import { apiClient } from '@/lib/axios'
import {
artistEngagementSchema,
performanceSchema,
stageSchema,
} from '@/schemas/timetable'
import type {
ArtistEngagement,
Performance,
Stage,
} from '@/types/timetable'
const stageArraySchema = z.array(stageSchema)
const performanceArraySchema = z.array(performanceSchema)
/**
* RFC v0.2 §6.2 — read-side composables for the timetable canvas.
* Server is authoritative for `lane_resolved` (D19); the client only
@@ -41,7 +50,7 @@ export function useStages(orgId: Ref<string>, eventId: Ref<string>) {
`/organisations/${orgId.value}/events/${eventId.value}/stages`,
)
return data.data
return stageArraySchema.parse(data.data)
},
enabled: () => !!orgId.value && !!eventId.value,
staleTime: 30_000,
@@ -70,7 +79,7 @@ export function usePerformances(
`/organisations/${orgId.value}/events/${eventId.value}/performances${params}`,
)
return data.data
return performanceArraySchema.parse(data.data)
},
enabled: () => !!orgId.value && !!eventId.value && !!dayId.value,
staleTime: 30_000,
@@ -92,7 +101,7 @@ export function useWachtrij(orgId: Ref<string>, eventId: Ref<string>) {
`/organisations/${orgId.value}/events/${eventId.value}/performances?stage_id=null`,
)
return data.data
return performanceArraySchema.parse(data.data)
},
enabled: () => !!orgId.value && !!eventId.value,
staleTime: 30_000,
@@ -114,7 +123,7 @@ export function useEngagement(orgId: Ref<string>, engagementId: Ref<string | nul
`/organisations/${orgId.value}/engagements/${engagementId.value}`,
)
return data.data
return artistEngagementSchema.parse(data.data)
},
enabled: () => !!orgId.value && !!engagementId.value,
staleTime: 30_000,

View File

@@ -4,6 +4,12 @@ import type { Ref } from 'vue'
import type { ApiResponse, ResourceCollection } from './useTimetable'
import { apiClient } from '@/lib/axios'
import { generateIdempotencyKey } from '@/lib/idempotencyKey'
import {
moveTimetableConflictSchema,
moveTimetableSuccessSchema,
performanceSchema,
stageSchema,
} from '@/schemas/timetable'
import type {
CreatePerformancePayload,
CreateStagePayload,
@@ -115,21 +121,25 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
MoveContext
>({
mutationFn: async ({ payload, idempotencyKey }) => {
let response
try {
const { data } = await apiClient.post<ApiResponse<MoveTimetableSuccess>>(
response = await apiClient.post<ApiResponse<MoveTimetableSuccess>>(
`/organisations/${orgId.value}/events/${eventId.value}/timetable/move`,
payload,
{ headers: { 'Idempotency-Key': idempotencyKey } },
)
return data.data
}
catch (err) {
if (isVersionMismatch(err)) {
// Backend canon: api/app/Http/Controllers/Api/V1/Artist/TimetableMoveController.php:64
// Parsing rejects drift in the conflict shape — schema mismatch
// surfaces as a thrown ZodError that GlitchTip / the global axios
// handler can fingerprint.
const conflict = moveTimetableConflictSchema.parse(err.response.data.errors)
const mismatch = new Error('version_mismatch') as Error & VersionMismatchError
mismatch.status = 409
mismatch.conflict = err.response.data.errors
mismatch.conflict = conflict
throw mismatch
}
const wrapped = new Error((err as AxiosError).message) as Error & { status: number; message: string }
@@ -137,6 +147,10 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
wrapped.status = (err as AxiosError).response?.status ?? 0
throw wrapped
}
// Outside the catch so a Zod parse failure on a 200 response surfaces
// as a true error (not silently re-routed through the 409 branch).
return moveTimetableSuccessSchema.parse(response.data.data)
},
onMutate: async ({ optimistic }) => {
await queryClient.cancelQueries({ queryKey: ['timetable', 'performances', eventId] })
@@ -179,7 +193,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
{ headers: { 'Idempotency-Key': generateIdempotencyKey() } },
)
return data.data
return performanceSchema.parse(data.data)
},
onSuccess: () => invalidate(),
})
@@ -193,7 +207,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
payload,
)
return data.data
return performanceSchema.parse(data.data)
},
onSuccess: updated => mergePerformance(updated),
})
@@ -260,7 +274,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
payload,
)
return data.data
return stageSchema.parse(data.data)
},
onSuccess: () => invalidateStages(),
})
@@ -272,7 +286,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
payload,
)
return data.data
return stageSchema.parse(data.data)
},
onSuccess: () => invalidateStages(),
})

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

View File

@@ -188,7 +188,19 @@ describe('useTimetableMutations', () => {
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' } },
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()