310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { useFormContext } from 'react-hook-form'
|
||
|
||
import EventEditForm from '@/features/events/edit/EventEditForm'
|
||
|
||
const push = vi.fn()
|
||
const addToast = vi.fn()
|
||
const fetchEventForEdit = vi.fn()
|
||
const fetchEventCategories = vi.fn()
|
||
const fetchAllCities = vi.fn()
|
||
const fetchEventMedia = vi.fn()
|
||
const fetchEventFaqs = vi.fn()
|
||
const updateEvent = vi.fn()
|
||
const updateEventAsAdmin = vi.fn()
|
||
|
||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
|
||
vi.mock('next/dynamic', () => ({
|
||
default: () => () => null,
|
||
}))
|
||
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
||
vi.mock('@/hooks/useAuth', () => ({ default: () => ({ user: { userId: 'user-id' } }) }))
|
||
vi.mock('@/services/events', () => ({
|
||
fetchEventForEdit: (...args: unknown[]) => fetchEventForEdit(...args),
|
||
fetchEventCategories: (...args: unknown[]) => fetchEventCategories(...args),
|
||
updateEvent: (...args: unknown[]) => updateEvent(...args),
|
||
updateEventAsAdmin: (...args: unknown[]) => updateEventAsAdmin(...args),
|
||
}))
|
||
vi.mock('@/services/eventDetail', () => ({
|
||
fetchEventMedia: (...args: unknown[]) => fetchEventMedia(...args),
|
||
fetchEventFaqs: (...args: unknown[]) => fetchEventFaqs(...args),
|
||
}))
|
||
vi.mock('@/features/events/edit/syncEventExtras', () => ({
|
||
syncEventMedia: vi.fn().mockResolvedValue(undefined),
|
||
syncEventFaqs: vi.fn().mockResolvedValue(undefined),
|
||
}))
|
||
vi.mock('@/services/geography', () => ({
|
||
fetchAllCities: (...args: unknown[]) => fetchAllCities(...args),
|
||
}))
|
||
vi.mock('@/components/events/create/EventMediaGalleryUploader', () => ({
|
||
default: () => <div data-testid="media-uploader" />,
|
||
}))
|
||
vi.mock('@/components/events/create/FaqEditor', () => ({
|
||
default: () => <div data-testid="faq-editor" />,
|
||
}))
|
||
vi.mock('@/components/events/create/CategoryTreeSelect', () => ({
|
||
default: function MockCategoryTreeSelect({ name, label }: { name: string; label: string }) {
|
||
const { register } = useFormContext()
|
||
|
||
return (
|
||
<label>
|
||
{label}
|
||
<input
|
||
aria-label={label}
|
||
{...register(name)}
|
||
/>
|
||
</label>
|
||
)
|
||
},
|
||
}))
|
||
vi.mock('@/components/events/create/MapLocationPicker', () => ({
|
||
default: () => <div data-testid="map-picker" />,
|
||
}))
|
||
vi.mock('@/components/forms/UnsavedChangesIndicator', () => ({ default: () => null }))
|
||
vi.mock('@/components/forms/ConsumerFormLayout', () => ({
|
||
ConsumerFormSection: ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||
<section>
|
||
<h2>{title}</h2>
|
||
{children}
|
||
</section>
|
||
),
|
||
ConsumerFormActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||
}))
|
||
vi.mock('@/components/forms/AdminFormLayout', () => ({
|
||
AdminFormSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||
AdminFormActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||
}))
|
||
vi.mock('@/components/formElements/Button', () => ({
|
||
default: ({ children, isLoading, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { isLoading?: boolean }) => (
|
||
<button
|
||
{...props}
|
||
disabled={props.disabled || isLoading}
|
||
>
|
||
{children}
|
||
</button>
|
||
),
|
||
}))
|
||
vi.mock('@/components/formElements/Input', () => ({
|
||
default: function MockInput({
|
||
name,
|
||
label,
|
||
disabled,
|
||
generalType,
|
||
}: {
|
||
name: string
|
||
label: string
|
||
disabled?: boolean
|
||
generalType?: string
|
||
}) {
|
||
const { register } = useFormContext()
|
||
const inputType = generalType === 'numberInput' || generalType === 'timePicker' ? 'number' : 'text'
|
||
|
||
if (generalType === 'switch') {
|
||
return (
|
||
<label>
|
||
{label}
|
||
<input
|
||
aria-label={label}
|
||
disabled={disabled}
|
||
type="checkbox"
|
||
{...register(name)}
|
||
/>
|
||
</label>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<label>
|
||
{label}
|
||
<input
|
||
aria-label={label}
|
||
disabled={disabled}
|
||
type={inputType}
|
||
{...register(name)}
|
||
/>
|
||
</label>
|
||
)
|
||
},
|
||
}))
|
||
|
||
const baseEvent = {
|
||
id: 'event-1',
|
||
title: 'رویداد تست',
|
||
slug: 'event-test',
|
||
status: 'published',
|
||
organizerId: 'user-id',
|
||
categoryId: 10,
|
||
shortDescription: 'کوتاه',
|
||
description: 'توضیح',
|
||
startsAt: '2030-01-01T10:00:00.000Z',
|
||
endsAt: '2030-01-01T12:00:00.000Z',
|
||
cityId: 2,
|
||
provinceId: 1,
|
||
address: 'تهران',
|
||
lat: 35.6892,
|
||
lng: 51.389,
|
||
isFree: false,
|
||
price: 100_000,
|
||
capacity: 20,
|
||
reservedCapacity: 0,
|
||
genderRestriction: 'open' as const,
|
||
ageRestriction: 'open' as const,
|
||
bookedCount: 3,
|
||
cancellationFeePercent: 10,
|
||
cancellationFeePercent12To24Hours: 10,
|
||
cancellationFeePercentMoreThan24Hours: 10,
|
||
settings: {
|
||
isDiscoverable: true,
|
||
autoCreateGroup: true,
|
||
addressVisibility: 'public' as const,
|
||
generalArea: null,
|
||
waitlistAutoOffer: true,
|
||
sendReviewRequestSms: false,
|
||
},
|
||
}
|
||
|
||
const requiredMedia = [
|
||
{
|
||
id: 'm1',
|
||
eventId: 'event-1',
|
||
mediaType: 'image' as const,
|
||
url: 'https://cdn.test/portrait.jpg',
|
||
sortOrder: 0,
|
||
isPoster: true,
|
||
isSquarePoster: false,
|
||
},
|
||
{
|
||
id: 'm2',
|
||
eventId: 'event-1',
|
||
mediaType: 'image' as const,
|
||
url: 'https://cdn.test/square.jpg',
|
||
sortOrder: 1,
|
||
isPoster: false,
|
||
isSquarePoster: true,
|
||
},
|
||
]
|
||
|
||
describe('EventEditForm', () => {
|
||
afterEach(cleanup)
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
fetchEventCategories.mockResolvedValue([{ id: 10, name: 'آموزشی', parentId: null, slug: 'edu', sortOrder: 1 }])
|
||
fetchAllCities.mockResolvedValue([{ id: 2, name: 'تهران', provinceId: 1 }])
|
||
fetchEventMedia.mockResolvedValue(requiredMedia)
|
||
fetchEventFaqs.mockResolvedValue([])
|
||
updateEvent.mockImplementation(async (_id: string, payload: Record<string, unknown>) => ({
|
||
...baseEvent,
|
||
...payload,
|
||
title: typeof payload.title === 'string' ? payload.title : baseEvent.title,
|
||
}))
|
||
})
|
||
|
||
it('shows the booking lock banner when bookings exist and never explains that all fields are editable', async () => {
|
||
fetchEventForEdit.mockResolvedValue(baseEvent)
|
||
|
||
render(
|
||
<EventEditForm
|
||
cancelHref="/my-events"
|
||
eventId="event-1"
|
||
successHref="/my-events"
|
||
/>
|
||
)
|
||
|
||
expect(await screen.findByText(/3 رزرو فعال دارد/)).toBeInTheDocument()
|
||
expect(screen.queryByText(/همه فیلدها قابل ویرایش/)).not.toBeInTheDocument()
|
||
expect(screen.getByLabelText('تاریخ شروع')).toBeDisabled()
|
||
expect(screen.getByLabelText('آدرس')).toBeDisabled()
|
||
expect(screen.getByLabelText('قیمت (تومان)')).toBeDisabled()
|
||
expect(screen.getByLabelText('لغو زودهنگام — بیش از ۲۴ ساعت مانده')).toBeDisabled()
|
||
expect(screen.getByLabelText('لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده')).toBeDisabled()
|
||
expect(screen.getByLabelText('لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده')).toBeDisabled()
|
||
expect(screen.getByLabelText('محدودیت جنسی')).toBeDisabled()
|
||
expect(screen.getByLabelText('محدودیت سنی')).toBeDisabled()
|
||
expect(screen.getByLabelText('عنوان')).not.toBeDisabled()
|
||
expect(screen.getByLabelText('اسلاگ')).not.toBeDisabled()
|
||
})
|
||
|
||
it('does not show a lock banner when published without bookings', async () => {
|
||
fetchEventForEdit.mockResolvedValue({ ...baseEvent, bookedCount: 0 })
|
||
|
||
render(
|
||
<EventEditForm
|
||
cancelHref="/my-events"
|
||
eventId="event-1"
|
||
successHref="/my-events"
|
||
/>
|
||
)
|
||
|
||
expect(await screen.findByLabelText('عنوان')).toBeInTheDocument()
|
||
expect(screen.queryByText(/رزرو فعال دارد/)).not.toBeInTheDocument()
|
||
expect(screen.queryByText(/همه فیلدها قابل ویرایش/)).not.toBeInTheDocument()
|
||
expect(screen.getByLabelText('تاریخ شروع')).not.toBeDisabled()
|
||
expect(screen.getByLabelText('آدرس')).not.toBeDisabled()
|
||
})
|
||
it('omits locked fields from the save payload', async () => {
|
||
fetchEventForEdit.mockResolvedValue(baseEvent)
|
||
|
||
render(
|
||
<EventEditForm
|
||
cancelHref="/my-events"
|
||
eventId="event-1"
|
||
successHref="/my-events"
|
||
/>
|
||
)
|
||
|
||
const title = await screen.findByLabelText('عنوان')
|
||
|
||
fireEvent.change(title, { target: { value: 'عنوان جدید' } })
|
||
fireEvent.click(screen.getByRole('button', { name: 'ذخیره تغییرات' }))
|
||
|
||
await waitFor(() => {
|
||
expect(updateEvent).toHaveBeenCalledOnce()
|
||
})
|
||
const payload = updateEvent.mock.calls[0][1] as Record<string, unknown>
|
||
|
||
expect(payload.title).toBe('عنوان جدید')
|
||
expect(payload).toHaveProperty('slug')
|
||
expect(payload).not.toHaveProperty('startsAt')
|
||
expect(payload).not.toHaveProperty('endsAt')
|
||
expect(payload).not.toHaveProperty('address')
|
||
expect(payload).not.toHaveProperty('price')
|
||
expect(payload).not.toHaveProperty('isFree')
|
||
expect(payload).not.toHaveProperty('cancellationFeePercent')
|
||
expect(payload).not.toHaveProperty('cancellationFeePercent12To24Hours')
|
||
expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours')
|
||
expect(payload).not.toHaveProperty('genderRestriction')
|
||
expect(payload).not.toHaveProperty('ageRestriction')
|
||
expect(updateEventAsAdmin).not.toHaveBeenCalled()
|
||
expect(push).toHaveBeenCalledWith('/my-events')
|
||
})
|
||
|
||
it('blocks saving when capacity drops below active bookings', async () => {
|
||
fetchEventForEdit.mockResolvedValue(baseEvent)
|
||
|
||
render(
|
||
<EventEditForm
|
||
cancelHref="/my-events"
|
||
eventId="event-1"
|
||
successHref="/my-events"
|
||
/>
|
||
)
|
||
|
||
const capacity = await screen.findByLabelText('ظرفیت')
|
||
|
||
fireEvent.change(capacity, { target: { value: '2' } })
|
||
fireEvent.click(screen.getByRole('button', { name: 'ذخیره تغییرات' }))
|
||
|
||
await waitFor(() => {
|
||
expect(addToast).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
title: expect.stringContaining('ظرفیت نمیتواند کمتر از صندلیهای اشغالشده'),
|
||
color: 'danger',
|
||
})
|
||
)
|
||
})
|
||
expect(updateEvent).not.toHaveBeenCalled()
|
||
})
|
||
})
|