From 71be107c5427a835ae34a8418859632db7532475 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 23 Apr 2026 14:09:05 +0200 Subject: [PATCH] test(portal): cover submitter details in useFormDraft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../useFormDraft.submitter.test.ts | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 apps/portal/tests/composables/useFormDraft.submitter.test.ts diff --git a/apps/portal/tests/composables/useFormDraft.submitter.test.ts b/apps/portal/tests/composables/useFormDraft.submitter.test.ts new file mode 100644 index 00000000..1a6d7bf2 --- /dev/null +++ b/apps/portal/tests/composables/useFormDraft.submitter.test.ts @@ -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 + 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 - 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() + }) +})