Files
crewli/apps/app/tests/component/AddPerformanceDialog.test.ts
bert.hausmans 8db6ca6024 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>
2026-05-09 03:43:03 +02:00

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)
})
})