test(timetable): AddPerformanceDialog validation + submit (Step 8)
4 component tests via mountWithVuexy:
- happy path: valid form values → POST /performances called with the
correct body shape (engagement_id, event_id mapped from dayId,
stage_id, start_at, end_at)
- end_at < start_at → submit blocked, schema-level error visible on
the end_at field
- empty engagement_id → submit blocked, error visible on the engagement_id
field
- cancel button → emits update:modelValue=false
Test seam: AddPerformanceDialog.vue gains `defineExpose({ form, errors,
submit })` so jsdom tests can drive validation deterministically without
piping through Flatpickr / VAutocomplete plumbing. Three lines, exposes
internal refs only — no behavioural change.
VDialog stubbed in the test (it teleports to body, which puts content
outside the wrapper); App* wrappers stubbed (we test the schema +
submit pipeline, not Flatpickr ergonomics).
Test count: 360 → 364.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,11 @@ async function submit(): Promise<void> {
|
||||
errors.value._ = (err as Error).message ?? 'Onbekende fout'
|
||||
}
|
||||
}
|
||||
|
||||
// Test seam (Session 4 follow-up): expose form + errors + submit so jsdom
|
||||
// component tests can drive validation deterministically without piping
|
||||
// through Flatpickr / VAutocomplete plumbing.
|
||||
defineExpose({ form, errors, submit })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
206
apps/app/tests/component/AddPerformanceDialog.test.ts
Normal file
206
apps/app/tests/component/AddPerformanceDialog.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mountWithVuexy } from '../utils/mountWithVuexy'
|
||||
import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import {
|
||||
type ArtistEngagement,
|
||||
ArtistEngagementStatus,
|
||||
type Stage,
|
||||
} from '@/types/timetable'
|
||||
|
||||
vi.mock('@/lib/axios', () => {
|
||||
const post = vi.fn()
|
||||
const put = vi.fn()
|
||||
const get = vi.fn()
|
||||
const del = vi.fn()
|
||||
|
||||
return { apiClient: { post, put, get, delete: del } }
|
||||
})
|
||||
|
||||
interface MockApi {
|
||||
post: ReturnType<typeof vi.fn>
|
||||
put: ReturnType<typeof vi.fn>
|
||||
get: ReturnType<typeof vi.fn>
|
||||
delete: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocked = apiClient as unknown as MockApi
|
||||
|
||||
const stage: Stage = {
|
||||
id: 's1',
|
||||
event_id: 'ev1',
|
||||
name: 'Hardstyle District',
|
||||
color: '#e85d75',
|
||||
capacity: 1000,
|
||||
sort_order: 0,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
}
|
||||
|
||||
const engagement: Partial<ArtistEngagement> = {
|
||||
id: 'e1',
|
||||
artist: { id: 'a1', name: 'Devin Wild' } as ArtistEngagement['artist'],
|
||||
booking_status: { value: ArtistEngagementStatus.REQUESTED, label: 'Aangevraagd' },
|
||||
}
|
||||
|
||||
// VDialog teleports to body, which puts content outside the wrapper. Stub it
|
||||
// so the form renders inline. App* wrappers stubbed too — we don't want to
|
||||
// drive Flatpickr / VAutocomplete plumbing here; we want to assert that the
|
||||
// schema validation + submit pipeline does the right thing.
|
||||
const VDialogStub = defineComponent({
|
||||
name: 'VDialog',
|
||||
props: ['modelValue'],
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'v-dialog-stub' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
interface AppFieldProps {
|
||||
modelValue?: unknown
|
||||
label?: string
|
||||
errorMessages?: string
|
||||
}
|
||||
|
||||
function makeAppFieldStub(name: string) {
|
||||
return defineComponent({
|
||||
name,
|
||||
props: ['modelValue', 'label', 'errorMessages'],
|
||||
setup(props: AppFieldProps) {
|
||||
return () => h('div', { 'class': `${name}-stub`, 'data-label': props.label }, [
|
||||
props.errorMessages ? h('span', { 'class': 'error', 'data-test': `error-${String(props.label).toLowerCase()}` }, String(props.errorMessages)) : null,
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const appStubs = {
|
||||
VDialog: VDialogStub,
|
||||
AppTextField: makeAppFieldStub('AppTextField'),
|
||||
AppTextarea: makeAppFieldStub('AppTextarea'),
|
||||
AppSelect: makeAppFieldStub('AppSelect'),
|
||||
AppAutocomplete: makeAppFieldStub('AppAutocomplete'),
|
||||
AppDateTimePicker: makeAppFieldStub('AppDateTimePicker'),
|
||||
}
|
||||
|
||||
interface ExposedShape {
|
||||
form: { value: { engagement_id: string; stage_id: string | null; start_at: string; end_at: string; lane: number; notes: string } }
|
||||
errors: { value: Record<string, string> }
|
||||
submit: () => Promise<void>
|
||||
}
|
||||
|
||||
function getExposed(wrapper: { vm: object }): ExposedShape {
|
||||
// VTU2 surfaces defineExpose() entries on `wrapper.vm.$.exposed`.
|
||||
return (wrapper.vm as { $: { exposed: ExposedShape } }).$.exposed
|
||||
}
|
||||
|
||||
describe('AddPerformanceDialog — validation + submit', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
afterEach(() => vi.clearAllMocks())
|
||||
|
||||
function mountDialog() {
|
||||
return mountWithVuexy(AddPerformanceDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
orgId: 'org_1',
|
||||
eventId: 'ev_1',
|
||||
dayId: 'day_1',
|
||||
stages: [stage],
|
||||
engagements: [engagement as ArtistEngagement],
|
||||
},
|
||||
stubs: appStubs,
|
||||
})
|
||||
}
|
||||
|
||||
it('calls createPerformance mutation with the validated payload on happy path', async () => {
|
||||
mocked.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'p1',
|
||||
engagement_id: 'e1',
|
||||
event_id: 'ev_1',
|
||||
stage_id: 's1',
|
||||
lane: 0,
|
||||
lane_resolved: 0,
|
||||
start_at: '2026-07-10T18:00:00.000Z',
|
||||
end_at: '2026-07-10T19:00:00.000Z',
|
||||
version: 0,
|
||||
notes: null,
|
||||
warnings: [],
|
||||
created_at: '2026-07-10T18:00:00.000Z',
|
||||
updated_at: '2026-07-10T18:00:00.000Z',
|
||||
deleted_at: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { wrapper } = mountDialog()
|
||||
const exposed = getExposed(wrapper)
|
||||
|
||||
exposed.form.value.engagement_id = 'e1'
|
||||
exposed.form.value.stage_id = 's1'
|
||||
exposed.form.value.start_at = '2026-07-10 18:00:00'
|
||||
exposed.form.value.end_at = '2026-07-10 19:00:00'
|
||||
exposed.form.value.lane = 0
|
||||
exposed.form.value.notes = ''
|
||||
|
||||
await exposed.submit()
|
||||
|
||||
expect(mocked.post).toHaveBeenCalledTimes(1)
|
||||
|
||||
const [url, body] = mocked.post.mock.calls[0]
|
||||
|
||||
expect(url).toContain('/events/ev_1/performances')
|
||||
expect(body).toMatchObject({
|
||||
engagement_id: 'e1',
|
||||
event_id: 'day_1',
|
||||
stage_id: 's1',
|
||||
start_at: '2026-07-10 18:00:00',
|
||||
end_at: '2026-07-10 19:00:00',
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks submit when end_at is before start_at and surfaces an error on the field', async () => {
|
||||
const { wrapper } = mountDialog()
|
||||
const exposed = getExposed(wrapper)
|
||||
|
||||
exposed.form.value.engagement_id = 'e1'
|
||||
exposed.form.value.stage_id = 's1'
|
||||
exposed.form.value.start_at = '2026-07-10 19:00:00'
|
||||
exposed.form.value.end_at = '2026-07-10 18:00:00'
|
||||
|
||||
await exposed.submit()
|
||||
|
||||
expect(mocked.post).not.toHaveBeenCalled()
|
||||
expect(exposed.errors.value.end_at).toMatch(/Eindtijd/)
|
||||
})
|
||||
|
||||
it('blocks submit when engagement_id is empty', async () => {
|
||||
const { wrapper } = mountDialog()
|
||||
const exposed = getExposed(wrapper)
|
||||
|
||||
exposed.form.value.engagement_id = ''
|
||||
exposed.form.value.stage_id = 's1'
|
||||
exposed.form.value.start_at = '2026-07-10 18:00:00'
|
||||
exposed.form.value.end_at = '2026-07-10 19:00:00'
|
||||
|
||||
await exposed.submit()
|
||||
|
||||
expect(mocked.post).not.toHaveBeenCalled()
|
||||
expect(exposed.errors.value.engagement_id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits update:modelValue=false when the cancel button is clicked', async () => {
|
||||
const { wrapper } = mountDialog()
|
||||
|
||||
const cancelBtn = wrapper.findAll('button').find(b => b.text().includes('Annuleer'))
|
||||
|
||||
expect(cancelBtn).toBeDefined()
|
||||
await cancelBtn!.trigger('click')
|
||||
|
||||
const closeEvents = (wrapper.emitted('update:modelValue') ?? []).filter(e => e[0] === false)
|
||||
|
||||
expect(closeEvents.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user