import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, ref } from 'vue' vi.mock('@/lib/axios', () => { const post = vi.fn() const put = vi.fn() const get = vi.fn() return { apiClient: { post, put, get } } }) import { apiClient } from '@/lib/axios' import { draftIdempotencyKey, useFormDraft } from '@/composables/useFormDraft' interface MockedApi { post: ReturnType put: ReturnType get: ReturnType } const mocked = apiClient as unknown as MockedApi function mountWithDraft(token: string, options?: Parameters[1]) { const draftRef: { value: ReturnType | null } = { value: null } const Host = defineComponent({ setup() { draftRef.value = useFormDraft(ref(token), options) return () => h('div') }, }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) const wrapper = mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] }, }) return { wrapper, draftRef } } function submissionFixture(id: string) { return { id, form_schema_id: 'sc_1', status: 'draft', auto_save_count: 0, submitted_in_locale: 'nl', schema_version_at_submit: null, schema_drift: false, values: {}, identity_match: null, opened_at: null, first_interacted_at: null, submitted_at: null, submission_duration_seconds: null, created_at: null, updated_at: null, } } function apiSuccess(body: T) { return { data: { data: body, message: 'ok', success: true } } } function flush(): Promise { return new Promise(resolve => setTimeout(resolve, 0)) } describe('useFormDraft', () => { const TOKEN = 'TKN123' beforeEach(() => { sessionStorage.clear() vi.clearAllMocks() }) afterEach(() => { sessionStorage.clear() }) it('derives the sessionStorage key from the token', () => { expect(draftIdempotencyKey('abc')).toBe('draft_idem:abc') }) it('generates and stores an idempotency_key on first start', async () => { mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) const { draftRef, wrapper } = mountWithDraft(TOKEN) const draft = draftRef.value expect(draft).toBeTruthy() await draft!.start() await flush() expect(mocked.post).toHaveBeenCalledTimes(1) const [, body] = mocked.post.mock.calls[0] expect(body.idempotency_key).toMatch(/^[a-f0-9]{8,30}$/i) expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBe(body.idempotency_key) wrapper.unmount() }) it('reuses the idempotency_key across remounts in the same session', async () => { mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1'))) const first = mountWithDraft(TOKEN) await first.draftRef.value!.start() await flush() const firstKey = mocked.post.mock.calls[0][1].idempotency_key first.wrapper.unmount() mocked.post.mockClear() mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1'))) const second = mountWithDraft(TOKEN) await second.draftRef.value!.start() await flush() const secondKey = mocked.post.mock.calls[0][1].idempotency_key expect(secondKey).toBe(firstKey) second.wrapper.unmount() }) it('debounces field saves and flushes on saveDraftNow', async () => { mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) mocked.put.mockResolvedValue(apiSuccess({ ...submissionFixture('s1'), auto_save_count: 1 })) const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 }) const draft = draftRef.value! await draft.start() await flush() expect(mocked.put).not.toHaveBeenCalled() draft.saveField('name', 'Alice') draft.saveField('name', 'Alice B') expect(mocked.put).not.toHaveBeenCalled() await draft.saveDraftNow() expect(mocked.put).toHaveBeenCalledTimes(1) const [, body] = mocked.put.mock.calls[0] expect(body.values).toEqual({ name: 'Alice B' }) wrapper.unmount() }) it('clears the sessionStorage key after a successful submit', async () => { mocked.post .mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start draft .mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' })) // submit const { draftRef, wrapper } = mountWithDraft(TOKEN) const draft = draftRef.value! await draft.start() await flush() expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeTruthy() // submitForm guards against empty submitter fields — populate them // so the composable actually hits the submit endpoint. draft.setSubmitterName('Test') draft.setSubmitterEmail('test@example.nl') const submitted = await draft.submitForm() await flush() expect(submitted?.status).toBe('submitted') expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeNull() wrapper.unmount() }) })