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 put: ReturnType get: ReturnType delete: ReturnType } 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 = { 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 } submit: () => Promise } 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) }) })