diff --git a/apps/app/src/composables/api/useTimetable.ts b/apps/app/src/composables/api/useTimetable.ts index f882e77f..14d4a86f 100644 --- a/apps/app/src/composables/api/useTimetable.ts +++ b/apps/app/src/composables/api/useTimetable.ts @@ -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, eventId: Ref) { `/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, eventId: Ref) { `/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, engagementId: Ref !!orgId.value && !!engagementId.value, staleTime: 30_000, diff --git a/apps/app/src/composables/api/useTimetableMutations.ts b/apps/app/src/composables/api/useTimetableMutations.ts index 8a65913c..8bee4ca1 100644 --- a/apps/app/src/composables/api/useTimetableMutations.ts +++ b/apps/app/src/composables/api/useTimetableMutations.ts @@ -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>( + response = await apiClient.post>( `/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(), }) diff --git a/apps/app/tests/unit/composables/api/zodParseFailure.test.ts b/apps/app/tests/unit/composables/api/zodParseFailure.test.ts new file mode 100644 index 00000000..12780061 --- /dev/null +++ b/apps/app/tests/unit/composables/api/zodParseFailure.test.ts @@ -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 + put: ReturnType + get: ReturnType + delete: ReturnType +} + +const mocked = apiClient as unknown as MockApi + +function mountWithMutations() { + const api: { value: ReturnType | null } = { value: null } + const orgId = ref('org_1') + const eventId = ref('ev_1') + const dayId = ref('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) + }) +}) diff --git a/apps/app/tests/unit/composables/useTimetableMutations.test.ts b/apps/app/tests/unit/composables/useTimetableMutations.test.ts index 7e4f2e3e..f1798066 100644 --- a/apps/app/tests/unit/composables/useTimetableMutations.test.ts +++ b/apps/app/tests/unit/composables/useTimetableMutations.test.ts @@ -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()