Files
crewli/apps/app/tests/unit/composables/useTimetableMutations.test.ts
bert.hausmans fbfe72d090 test(timetable): useTimetableMutations 409 rollback + idempotency-key semantics (Step 9)
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>
2026-05-09 03:48:39 +02:00

217 lines
5.9 KiB
TypeScript

import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import type { Performance } from '@/types/timetable'
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 p(overrides: Partial<Performance> = {}): Performance {
return {
id: 'p1',
engagement_id: 'e1',
event_id: 'ev1',
stage_id: 's1',
lane: 0,
lane_resolved: 0,
start_at: '2026-07-10T18:00:00.000Z',
end_at: '2026-07-10T19:00:00.000Z',
version: 3,
notes: null,
warnings: [],
created_at: null,
updated_at: null,
deleted_at: null,
...overrides,
}
}
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()
const wrapper = mount(Host, {
global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] },
})
return { wrapper, api, queryClient }
}
describe('useTimetableMutations', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('move', () => {
it('sends Idempotency-Key header on POST /timetable/move', async () => {
mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: p({ version: 4 }), cascaded: [] } } })
const { api } = mountWithMutations()
await 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-key-12345',
})
expect(mocked.post).toHaveBeenCalledTimes(1)
const [, , config] = mocked.post.mock.calls[0]
expect(config.headers['Idempotency-Key']).toBe('idem-test-key-12345')
})
it('applies optimistic patch + cascade on success', async () => {
mocked.post.mockResolvedValueOnce({
data: {
success: true,
data: {
moved: p({ id: 'p1', version: 4 }),
cascaded: [p({ id: 'p2', lane: 1, lane_resolved: 1, version: 4 })],
},
},
})
const { api, queryClient } = mountWithMutations()
const eventId = ref('ev_1')
const dayId = ref<string | null>('day_1')
queryClient.setQueryData(['timetable', 'performances', eventId, dayId], [
p({ id: 'p1' }),
p({ id: 'p2', lane: 0, lane_resolved: 0 }),
])
const result = await 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',
})
expect(result.cascaded).toHaveLength(1)
expect(result.moved.version).toBe(4)
})
it('surfaces VersionMismatch on 409', async () => {
mocked.post.mockRejectedValueOnce({
response: {
status: 409,
data: {
errors: {
conflict: 'version_mismatch',
current_version: 5,
client_version: 3,
server_data: p({ version: 5 }),
},
},
},
})
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',
})).rejects.toMatchObject({ status: 409, conflict: { conflict: 'version_mismatch', current_version: 5 } })
})
})
describe('park / unpark via move', () => {
it('park sends target_stage_id null', async () => {
mocked.post.mockResolvedValueOnce({
data: { success: true, data: { moved: p({ stage_id: null, version: 4 }), cascaded: [] } },
})
const { api } = mountWithMutations()
await api.value!.park(p(), 'key1')
const [, body] = mocked.post.mock.calls[0]
expect(body.target_stage_id).toBe(null)
expect(body.version).toBe(3)
})
})
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',
created_at: '2026-07-10T18:00:00.000Z',
updated_at: '2026-07-10T18:00:00.000Z',
},
},
})
const { api } = mountWithMutations()
await api.value!.createStage.mutateAsync({ name: 'New Stage', color: '#aabbcc', capacity: 1000 })
expect(mocked.post.mock.calls[0][0]).toContain('/stages')
})
})
})