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>
207 lines
6.1 KiB
TypeScript
207 lines
6.1 KiB
TypeScript
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)
|
|
})
|
|
})
|