refactor(portal): move composables, types, schemas; drop duplicates
Composables (apps/portal/src/composables → apps/app/src/composables/):
- useFormDraft, publicFormInjection → composables/ (root, used by
shared/public-form components)
- api/usePublicForm, api/usePublicFormSections,
api/usePublicFormTimeSlots → composables/api/ (no collisions)
- api/usePortalShifts, api/usePortalProfile, api/useVolunteerRegistration
→ composables/api/portal/ (subfolder per WS-3 PR-B1 charter to
leave room for organizer-side namesakes without clashes)
- api/useMfa → DELETED (apps/app version is a strict superset
with extra invalidateQueries calls and the admin-reset mutation)
Types (apps/portal/src/types → apps/app/src/types/):
- api, portal-shift, portal, registration → moved
- mfa → DELETED (byte-identical to apps/app/src/types/mfa.ts)
Schemas:
- apps/portal/src/schemas/registrationSchema.ts → apps/app/src/schemas/
Utils:
- deviceFingerprint, paginationMeta → DELETED (byte-identical
duplicates already in apps/app/src/utils/)
Lib:
- apps/portal/src/lib/{axios,query-client}.ts → DELETED. apps/app's
callback-bound axios (post-PR-A) and query-client are the
canonical versions. Portal pages currently importing
`@/lib/axios#apiClient` resolve to apps/app's apiClient with no
behavioral change for cookie-based requests.
Tests: 4 composable specs (useFormDraft x2, usePublicFormSections,
usePublicFormTimeSlots) moved into __tests__/ subdirs alongside
their composables.
@form-schema imports inside the moved files rewritten to
@/composables/forms/*.
Vitest now: 23 files / 162 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
174
apps/app/src/composables/__tests__/useFormDraft.test.ts
Normal file
174
apps/app/src/composables/__tests__/useFormDraft.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user