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