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:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
128
apps/app/tests/unit/composables/api/zodParseFailure.test.ts
Normal file
128
apps/app/tests/unit/composables/api/zodParseFailure.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user