diff --git a/apps/app/src/composables/api/useTimetableMutations.ts b/apps/app/src/composables/api/useTimetableMutations.ts index 8bee4ca1..a72369a3 100644 --- a/apps/app/src/composables/api/useTimetableMutations.ts +++ b/apps/app/src/composables/api/useTimetableMutations.ts @@ -10,6 +10,7 @@ import { performanceSchema, stageSchema, } from '@/schemas/timetable' +import { useNotificationStore } from '@/stores/useNotificationStore' import type { CreatePerformancePayload, CreateStagePayload, @@ -64,6 +65,7 @@ function isVersionMismatch(err: unknown): err is { response: { status: 409; data export function useTimetableMutations(args: UseTimetableMutationsArgs) { const queryClient = useQueryClient() + const notification = useNotificationStore() const { orgId, eventId, dayId } = args const performancesKey = () => ['timetable', 'performances', eventId, dayId] as const @@ -173,13 +175,19 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { mergePerformance(result.moved) applyCascade(result.cascaded) }, - onError: (_err, _vars, ctx) => { + onError: (err, _vars, ctx) => { // Restore cached blocks from snapshot so the canvas snaps back. if (ctx?.snapshot) mergePerformance(ctx.snapshot) if (ctx?.snapshotWachtrij) mergePerformance(ctx.snapshotWachtrij) invalidate() + + // RFC D14 — version mismatch toast. Generic axios errors stay quiet + // here; they're already surfaced by the global response handler in + // lib/axios/factory.ts. + if ((err as { status?: number } | null)?.status === 409) + notification.show('Iemand anders heeft dit zojuist aangepast — venster ververst.', 'error') }, }) diff --git a/apps/app/tests/component/useTimetableMutations.test.ts b/apps/app/tests/component/useTimetableMutations.test.ts new file mode 100644 index 00000000..7f767d06 --- /dev/null +++ b/apps/app/tests/component/useTimetableMutations.test.ts @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { mountWithVuexy } from '../utils/mountWithVuexy' +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 + put: ReturnType + get: ReturnType + delete: ReturnType +} + +const mocked = apiClient as unknown as MockApi + +function perf(overrides: Partial = {}): Performance { + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev_1', + 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: '2026-07-10T18:00:00.000Z', + updated_at: '2026-07-10T18:00:00.000Z', + deleted_at: null, + ...overrides, + } +} + +interface ExposedShape { + api: ReturnType +} + +function mountMutations() { + const Host = defineComponent({ + setup(_, { expose }) { + const api = useTimetableMutations({ + orgId: ref('org_1'), + eventId: ref('ev_1'), + dayId: ref('day_1'), + }) + + expose({ api }) + + return () => h('div') + }, + }) + + return mountWithVuexy(Host) +} + +function getApi(wrapper: { vm: object }): ReturnType { + return (wrapper.vm as { $: { exposed: ExposedShape } }).$.exposed.api +} + +describe('useTimetableMutations.move — optimistic + 409 + idempotency-key', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.useRealTimers()) + + it('on success: returns the server payload with the bumped version + sends Idempotency-Key', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: perf({ version: 4, start_at: '2026-07-10T20:00:00.000Z', end_at: '2026-07-10T21:00:00.000Z' }), + cascaded: [], + }, + }, + }) + + const { wrapper } = mountMutations() + const api = getApi(wrapper) + + const result = await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-A', + optimistic: perf({ start_at: '2026-07-10T20:00:00.000Z', end_at: '2026-07-10T21:00:00.000Z' }), + }) + + expect(result.moved.version).toBe(4) + expect(mocked.post).toHaveBeenCalledTimes(1) + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe('idem-A') + }) + + it('409 path: rolls back, surfaces VersionMismatchError, and shows notification', async () => { + mocked.post.mockRejectedValueOnce({ + response: { + status: 409, + data: { + errors: { + conflict: 'version_mismatch', + current_version: 7, + client_version: 3, + server_data: perf({ version: 7 }), + }, + }, + }, + }) + + const { wrapper, notificationMock } = mountMutations() + const api = getApi(wrapper) + + await expect(api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-409', + })).rejects.toMatchObject({ status: 409, conflict: { conflict: 'version_mismatch', current_version: 7 } }) + + expect(notificationMock.show).toHaveBeenCalledTimes(1) + expect(notificationMock.show).toHaveBeenCalledWith(expect.stringMatching(/zojuist aangepast/i), 'error') + }) + + it('cascade: success with cascaded[] non-empty puts those peers into the cache', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: perf({ version: 4 }), + cascaded: [perf({ id: 'p2', lane: 1, lane_resolved: 1, version: 4 })], + }, + }, + }) + + const { wrapper } = mountMutations() + const api = getApi(wrapper) + + const result = await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-cascade', + }) + + expect(result.cascaded).toHaveLength(1) + expect(result.cascaded[0].id).toBe('p2') + }) + + it('Idempotency-Key: each logical move() call sends the exact key the caller supplied', async () => { + mocked.post + .mockResolvedValueOnce({ + data: { + success: true, + data: { moved: perf({ version: 4 }), cascaded: [] }, + }, + }) + .mockResolvedValueOnce({ + data: { + success: true, + data: { moved: perf({ version: 5 }), cascaded: [] }, + }, + }) + + const { wrapper } = mountMutations() + const api = getApi(wrapper) + + await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-action-A', + }) + + await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 21:00:00', + target_end_at: '2026-07-10 22:00:00', + target_lane: 0, + version: 4, + }, + idempotencyKey: 'idem-action-B', + }) + + expect(mocked.post).toHaveBeenCalledTimes(2) + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe('idem-action-A') + expect(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']).toBe('idem-action-B') + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']) + .not.toBe(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']) + }) + + it('Idempotency-Key: explicit retry within the same logical action reuses the same key', async () => { + // Caller-controlled retry: the page catches a transient network error + // and re-invokes move() with the SAME idempotencyKey it generated for + // that drag. That call must carry the same header on the wire so the + // backend dedupes it. + mocked.post + .mockRejectedValueOnce({ message: 'Network down', response: undefined }) + .mockResolvedValueOnce({ + data: { + success: true, + data: { moved: perf({ version: 4 }), cascaded: [] }, + }, + }) + + const { wrapper } = mountMutations() + const api = getApi(wrapper) + + const sameKey = 'idem-drag-XYZ' + + const payload = { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + } as const + + await expect(api.move.mutateAsync({ payload, idempotencyKey: sameKey })).rejects.toBeTruthy() + await api.move.mutateAsync({ payload, idempotencyKey: sameKey }) + + expect(mocked.post).toHaveBeenCalledTimes(2) + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe(sameKey) + expect(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']).toBe(sameKey) + }) +}) diff --git a/apps/app/tests/unit/composables/api/zodParseFailure.test.ts b/apps/app/tests/unit/composables/api/zodParseFailure.test.ts index 12780061..610380a5 100644 --- a/apps/app/tests/unit/composables/api/zodParseFailure.test.ts +++ b/apps/app/tests/unit/composables/api/zodParseFailure.test.ts @@ -1,5 +1,6 @@ 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' @@ -39,14 +40,18 @@ function mountWithMutations() { }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const pinia = createPinia() - mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] } }) + mount(Host, { global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] } }) return { api } } describe('Zod parse failure on API responses', () => { - beforeEach(() => vi.clearAllMocks()) + 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. diff --git a/apps/app/tests/unit/composables/useTimetableMutations.test.ts b/apps/app/tests/unit/composables/useTimetableMutations.test.ts index f1798066..6385a674 100644 --- a/apps/app/tests/unit/composables/useTimetableMutations.test.ts +++ b/apps/app/tests/unit/composables/useTimetableMutations.test.ts @@ -1,5 +1,6 @@ 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' @@ -59,9 +60,10 @@ function mountWithMutations() { }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const pinia = createPinia() const wrapper = mount(Host, { - global: { plugins: [[VueQueryPlugin, { queryClient }]] }, + global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] }, }) return { wrapper, api, queryClient } @@ -69,6 +71,7 @@ function mountWithMutations() { describe('useTimetableMutations', () => { beforeEach(() => { + setActivePinia(createPinia()) vi.clearAllMocks() })