Adds submitterName/submitterEmail state and setters to useFormDraft and
wires them through start/saveDraft/submit. Previously the Contactgegevens
name/email were held in a local page ref and never made it into any
request body, so submissions landed in the DB with NULL submitter fields
and a mid-form reload wiped whatever the user had typed.
- useFormDraft: internal submitterName/submitterEmail refs with setters
that mark the draft dirty (same debounced-PUT path as field values),
sessionStorage resume via draft_submitter:{token}, and a
MISSING_SUBMITTER guard in submitForm so empty fields surface as
submitError without hitting the endpoint.
- register/[public_token].vue: deletes the local submitter refs and
reads/writes through the composable; onSubmit pre-validates and
bounces the user back to the Contactgegevens step with a snackbar
when fields are missing.
- SaveDraftBody / SubmitBody: optional public_submitter_name and
public_submitter_email per the documented backend contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
5.0 KiB
TypeScript
175 lines
5.0 KiB
TypeScript
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<typeof vi.fn>
|
|
put: ReturnType<typeof vi.fn>
|
|
get: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
const mocked = apiClient as unknown as MockedApi
|
|
|
|
function mountWithDraft(token: string, options?: Parameters<typeof useFormDraft>[1]) {
|
|
const draftRef: { value: ReturnType<typeof useFormDraft> | 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<T>(body: T) {
|
|
return { data: { data: body, message: 'ok', success: true } }
|
|
}
|
|
|
|
function flush(): Promise<void> {
|
|
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()
|
|
})
|
|
})
|