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: () =>
,
}))
vi.mock('@/components/events/create/FaqEditor', () => ({
default: () => ,
}))
vi.mock('@/components/events/create/CategoryTreeSelect', () => ({
default: function MockCategoryTreeSelect({ name, label }: { name: string; label: string }) {
const { register } = useFormContext()
return (
)
},
}))
vi.mock('@/components/events/create/MapLocationPicker', () => ({
default: () => ,
}))
vi.mock('@/components/forms/UnsavedChangesIndicator', () => ({ default: () => null }))
vi.mock('@/components/forms/ConsumerFormLayout', () => ({
ConsumerFormSection: ({ title, children }: { title: string; children: React.ReactNode }) => (
),
ConsumerFormActions: ({ children }: { children: React.ReactNode }) => {children}
,
}))
vi.mock('@/components/forms/AdminFormLayout', () => ({
AdminFormSection: ({ children }: { children: React.ReactNode }) => {children}
,
AdminFormActions: ({ children }: { children: React.ReactNode }) => {children}
,
}))
vi.mock('@/components/formElements/Button', () => ({
default: ({ children, isLoading, ...props }: React.ButtonHTMLAttributes & { isLoading?: boolean }) => (
),
}))
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 (
)
}
return (
)
},
}))
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,
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) => ({
...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(
)
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('عنوان')).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(
)
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(
)
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
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('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(
)
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()
})
})