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 { useQuery } from '@tanstack/vue-query'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { z } from 'zod'
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
|
import {
|
||||||
|
artistEngagementSchema,
|
||||||
|
performanceSchema,
|
||||||
|
stageSchema,
|
||||||
|
} from '@/schemas/timetable'
|
||||||
import type {
|
import type {
|
||||||
ArtistEngagement,
|
ArtistEngagement,
|
||||||
Performance,
|
Performance,
|
||||||
Stage,
|
Stage,
|
||||||
} from '@/types/timetable'
|
} 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.
|
* RFC v0.2 §6.2 — read-side composables for the timetable canvas.
|
||||||
* Server is authoritative for `lane_resolved` (D19); the client only
|
* 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`,
|
`/organisations/${orgId.value}/events/${eventId.value}/stages`,
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
return stageArraySchema.parse(data.data)
|
||||||
},
|
},
|
||||||
enabled: () => !!orgId.value && !!eventId.value,
|
enabled: () => !!orgId.value && !!eventId.value,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -70,7 +79,7 @@ export function usePerformances(
|
|||||||
`/organisations/${orgId.value}/events/${eventId.value}/performances${params}`,
|
`/organisations/${orgId.value}/events/${eventId.value}/performances${params}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
return performanceArraySchema.parse(data.data)
|
||||||
},
|
},
|
||||||
enabled: () => !!orgId.value && !!eventId.value && !!dayId.value,
|
enabled: () => !!orgId.value && !!eventId.value && !!dayId.value,
|
||||||
staleTime: 30_000,
|
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`,
|
`/organisations/${orgId.value}/events/${eventId.value}/performances?stage_id=null`,
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
return performanceArraySchema.parse(data.data)
|
||||||
},
|
},
|
||||||
enabled: () => !!orgId.value && !!eventId.value,
|
enabled: () => !!orgId.value && !!eventId.value,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -114,7 +123,7 @@ export function useEngagement(orgId: Ref<string>, engagementId: Ref<string | nul
|
|||||||
`/organisations/${orgId.value}/engagements/${engagementId.value}`,
|
`/organisations/${orgId.value}/engagements/${engagementId.value}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
return artistEngagementSchema.parse(data.data)
|
||||||
},
|
},
|
||||||
enabled: () => !!orgId.value && !!engagementId.value,
|
enabled: () => !!orgId.value && !!engagementId.value,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import type { Ref } from 'vue'
|
|||||||
import type { ApiResponse, ResourceCollection } from './useTimetable'
|
import type { ApiResponse, ResourceCollection } from './useTimetable'
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import { generateIdempotencyKey } from '@/lib/idempotencyKey'
|
import { generateIdempotencyKey } from '@/lib/idempotencyKey'
|
||||||
|
import {
|
||||||
|
moveTimetableConflictSchema,
|
||||||
|
moveTimetableSuccessSchema,
|
||||||
|
performanceSchema,
|
||||||
|
stageSchema,
|
||||||
|
} from '@/schemas/timetable'
|
||||||
import type {
|
import type {
|
||||||
CreatePerformancePayload,
|
CreatePerformancePayload,
|
||||||
CreateStagePayload,
|
CreateStagePayload,
|
||||||
@@ -115,21 +121,25 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
|
|||||||
MoveContext
|
MoveContext
|
||||||
>({
|
>({
|
||||||
mutationFn: async ({ payload, idempotencyKey }) => {
|
mutationFn: async ({ payload, idempotencyKey }) => {
|
||||||
|
let response
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post<ApiResponse<MoveTimetableSuccess>>(
|
response = await apiClient.post<ApiResponse<MoveTimetableSuccess>>(
|
||||||
`/organisations/${orgId.value}/events/${eventId.value}/timetable/move`,
|
`/organisations/${orgId.value}/events/${eventId.value}/timetable/move`,
|
||||||
payload,
|
payload,
|
||||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
if (isVersionMismatch(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
|
const mismatch = new Error('version_mismatch') as Error & VersionMismatchError
|
||||||
|
|
||||||
mismatch.status = 409
|
mismatch.status = 409
|
||||||
mismatch.conflict = err.response.data.errors
|
mismatch.conflict = conflict
|
||||||
throw mismatch
|
throw mismatch
|
||||||
}
|
}
|
||||||
const wrapped = new Error((err as AxiosError).message) as Error & { status: number; message: string }
|
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
|
wrapped.status = (err as AxiosError).response?.status ?? 0
|
||||||
throw wrapped
|
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 }) => {
|
onMutate: async ({ optimistic }) => {
|
||||||
await queryClient.cancelQueries({ queryKey: ['timetable', 'performances', eventId] })
|
await queryClient.cancelQueries({ queryKey: ['timetable', 'performances', eventId] })
|
||||||
@@ -179,7 +193,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
|
|||||||
{ headers: { 'Idempotency-Key': generateIdempotencyKey() } },
|
{ headers: { 'Idempotency-Key': generateIdempotencyKey() } },
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
return performanceSchema.parse(data.data)
|
||||||
},
|
},
|
||||||
onSuccess: () => invalidate(),
|
onSuccess: () => invalidate(),
|
||||||
})
|
})
|
||||||
@@ -193,7 +207,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
|
|||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
return performanceSchema.parse(data.data)
|
||||||
},
|
},
|
||||||
onSuccess: updated => mergePerformance(updated),
|
onSuccess: updated => mergePerformance(updated),
|
||||||
})
|
})
|
||||||
@@ -260,7 +274,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
|
|||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
return stageSchema.parse(data.data)
|
||||||
},
|
},
|
||||||
onSuccess: () => invalidateStages(),
|
onSuccess: () => invalidateStages(),
|
||||||
})
|
})
|
||||||
@@ -272,7 +286,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) {
|
|||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return data.data
|
return stageSchema.parse(data.data)
|
||||||
},
|
},
|
||||||
onSuccess: () => invalidateStages(),
|
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', () => {
|
describe('createStage', () => {
|
||||||
it('hits POST /stages', async () => {
|
it('hits POST /stages', async () => {
|
||||||
mocked.post.mockResolvedValueOnce({
|
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()
|
const { api } = mountWithMutations()
|
||||||
|
|||||||
Reference in New Issue
Block a user