test(portal): cover submitter details in useFormDraft

Adds nine cases against useFormDraft's submitter surface. The S3a PR 1
smoke test found that submitter name/email were never sent to the
backend — a proper test would have caught that.

Covers: initial empty state, setter dirty-tracking flowing into the PUT
body, both name and email in the POST /submit body, the
MISSING_SUBMITTER guard when either field is empty (no endpoint call),
sessionStorage resume populating state and the initial start POST, and
session cleanup after successful submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:09:05 +02:00
parent f5f3c99fb1
commit 71be107c54

View File

@@ -0,0 +1,267 @@
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 { draftSubmitterStorageKey, 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 - submitter details', () => {
const TOKEN = 'TKN-sub'
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
})
afterEach(() => {
sessionStorage.clear()
})
it('starts with empty submitterName and submitterEmail', () => {
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
expect(draft.submitterName.value).toBe('')
expect(draft.submitterEmail.value).toBe('')
wrapper.unmount()
})
it('setSubmitterName marks the draft dirty — subsequent flush includes public_submitter_name in the PUT body', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
mocked.put.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 })
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterName('Test User')
await draft.saveDraftNow()
expect(mocked.put).toHaveBeenCalledTimes(1)
const [, body] = mocked.put.mock.calls[0]
expect(body.public_submitter_name).toBe('Test User')
wrapper.unmount()
})
it('setSubmitterEmail flows through to the PUT body on flush', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
mocked.put.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 })
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterEmail('user@example.nl')
await draft.saveDraftNow()
expect(mocked.put).toHaveBeenCalledTimes(1)
const [, body] = mocked.put.mock.calls[0]
expect(body.public_submitter_email).toBe('user@example.nl')
wrapper.unmount()
})
it('submitForm sends submitter name and email in the POST /submit body', async () => {
mocked.post
.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start
.mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' })) // submit
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterName('Alice')
draft.setSubmitterEmail('alice@example.nl')
const result = await draft.submitForm()
await flush()
expect(result).toBeTruthy()
expect(mocked.post).toHaveBeenCalledTimes(2)
const [, submitBody] = mocked.post.mock.calls[1]
expect(submitBody.public_submitter_name).toBe('Alice')
expect(submitBody.public_submitter_email).toBe('alice@example.nl')
wrapper.unmount()
})
// Contract: if submitter fields are empty at submit time, submitForm
// returns null, sets submitError with code 'MISSING_SUBMITTER', and
// does NOT hit the submit endpoint — the page is expected to read
// submitError and bounce the user back to the Contactgegevens step.
it('submitForm returns null and sets submitError when submitter fields are empty', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start only
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
expect(mocked.post).toHaveBeenCalledTimes(1)
const result = await draft.submitForm()
expect(result).toBeNull()
const err = draft.submitError.value as { code?: string } | null
expect(err).toBeTruthy()
expect(err?.code).toBe('MISSING_SUBMITTER')
// Submit endpoint was NOT called — only the start POST is in the log.
expect(mocked.post).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('submitForm refuses to submit when only one of name/email is set', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterName('Bob')
// email intentionally left empty
const result = await draft.submitForm()
expect(result).toBeNull()
const err = draft.submitError.value as { code?: string } | null
expect(err?.code).toBe('MISSING_SUBMITTER')
expect(mocked.post).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('restores submitter details from sessionStorage on start and includes them in the initial POST body', async () => {
sessionStorage.setItem(
draftSubmitterStorageKey(TOKEN),
JSON.stringify({ name: 'Resumed', email: 'resumed@example.nl' }),
)
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
expect(draft.submitterName.value).toBe('Resumed')
expect(draft.submitterEmail.value).toBe('resumed@example.nl')
const [, body] = mocked.post.mock.calls[0]
expect(body.public_submitter_name).toBe('Resumed')
expect(body.public_submitter_email).toBe('resumed@example.nl')
wrapper.unmount()
})
it('persists submitter details to sessionStorage when set', () => {
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
draft.setSubmitterName('Persist Me')
draft.setSubmitterEmail('persist@example.nl')
const raw = sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))
expect(raw).toBeTruthy()
const parsed = JSON.parse(raw as string)
expect(parsed).toEqual({ name: 'Persist Me', email: 'persist@example.nl' })
wrapper.unmount()
})
it('clears the submitter sessionStorage entry after a successful submit', async () => {
mocked.post
.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
.mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' }))
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterName('Cleaner')
draft.setSubmitterEmail('cleaner@example.nl')
expect(sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))).toBeTruthy()
await draft.submitForm()
await flush()
expect(sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))).toBeNull()
wrapper.unmount()
})
})