Routing wiring (Phase D of WS-3 PR-B1):
- apps/app/src/plugins/1.router/guards.ts: add a single early-return
carve-out before the org-selection redirect — `if (to.meta.context
=== 'portal') return`. Per ARCH-CONSOLIDATION-2026-04 §4.3,
meta.context is the canonical contract; PR-B2 evolves the guards
from this key to full context-aware logic (post-login landing,
context-switcher, role checks).
- apps/app/env.d.ts: extend RouteMeta with the new layout names
('OrganizerLayout' | 'PortalLayout' | 'PublicLayout'), context,
requiresAuth, requiresToken, navMode, navTitle.
- apps/app/typed-router.d.ts: regenerated by unplugin-vue-router to
pick up portal/* and register/* route names.
- Page meta finalisation: portal pages have layout: 'PortalLayout',
context: 'portal', preserving original requiresAuth + nav fields;
register pages have layout: 'PublicLayout' + public: true (the
apps/app guard convention for public routes, since meta.public is
what the existing guard recognises).
Form-types restructure (boundaries cleanup):
- apps/app/src/composables/forms/types/formBuilder.ts → src/types/forms/
- apps/app/src/composables/forms/utils/{formValidation,validators}.ts
→ src/utils/forms/
- All `@/composables/forms/{types,utils}/*` imports rewritten across
pages, components, composables, tests.
- This avoids a `types → composables` boundaries violation at
src/types/formSchema.ts which re-exports primitives from the
inlined form-schema. types/formSchema.ts now imports from
@/types/forms/formBuilder which is in the same boundaries zone.
Lint cleanup for moved portal sources (apps/portal had no
.eslintrc.cjs; the migrated code now has to pass apps/app's stricter
config):
- axios.isAxiosError → named import { isAxiosError }
(ClaimenTab, RoosterTab, profiel.vue)
- void schemaQuery.refetch() → schemaQuery.refetch()
(register/[public_token].vue)
- if-then-else collapsed to single boolean return (formatFieldValue)
- :delay-on-touch-only="true" → delay-on-touch-only shorthand
(FieldSectionPriority)
- ml-2 class → ms-2 (FieldAvailabilityPicker)
- multi-statement-per-line splits in profiel.vue + spec files
- unused emailConfigured ref removed (profiel.vue)
- one-component-per-file disabled with TODO TECH-WS3-PORTAL-LINT-CLEANUP
ref (FieldOptionsLocale.spec.ts — multi-Wrapper test pattern)
- restored `import Draggable from 'vuedraggable'` after lint:fix
removed it (template-only usage; the import IS needed)
- camelcase param renamed in FieldOptionsLocale harness factory
- typecheck nudge: spec state.data typed via PublicFormSectionOption[] /
PublicFormTimeSlot[] aliases instead of Record<string, unknown>
- PortalLayout.vue: explicit `import { useRoute, useRouter }` so the
vitest mock can intercept (the trimmed AutoImport set doesn't pull
vue-router's auto-imports)
Vitest: 23 / 162 passing. Lint: 0 errors / 0 new warnings (only the
pre-existing boundaries v5→v6 deprecation warnings remain). Typecheck:
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
287 lines
8.1 KiB
TypeScript
287 lines
8.1 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'
|
|
|
|
import { apiClient } from '@/lib/axios'
|
|
import { draftSubmitterStorageKey, useFormDraft } from '@/composables/useFormDraft'
|
|
|
|
vi.mock('@/lib/axios', () => {
|
|
const post = vi.fn()
|
|
const put = vi.fn()
|
|
const get = vi.fn()
|
|
|
|
return { apiClient: { post, put, get } }
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|