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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user