Minimal seam in src/composables/api/useTimetableMutations.ts: the move()
mutation's onError now calls useNotificationStore().show(...) on a 409
status. Generic axios errors stay quiet here — the global response
handler in lib/axios/factory.ts already toasts those. RFC D14 wanted
the version-mismatch toast specifically.
apps/app/tests/component/useTimetableMutations.test.ts (NEW, 5 tests):
- on success: returns server payload with bumped version + sends the
Idempotency-Key supplied by the caller
- 409: rejects with VersionMismatchError + notification.show()
invoked once with the Dutch translation + 'error' level
- cascade: success with cascaded[] populated puts those peers into
the result.cascaded array
- Idempotency-Key uniqueness: two distinct logical move() calls send
distinct keys
- Idempotency-Key reuse: caller-controlled retry within the same
logical action sends the SAME key on the wire (so the backend's
60s idempotency middleware dedupes)
The two existing unit-project tests now register a Pinia instance
(createPinia + setActivePinia) so useNotificationStore() resolves.
Existing assertions unchanged.
Test count: 364 → 369.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
4.0 KiB
TypeScript
134 lines
4.0 KiB
TypeScript
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
|
import { mount } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
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 } } })
|
|
const pinia = createPinia()
|
|
|
|
mount(Host, { global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] } })
|
|
|
|
return { api }
|
|
}
|
|
|
|
describe('Zod parse failure on API responses', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
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)
|
|
})
|
|
})
|