admin/features/events/edit/EventEditForm.test.tsx

310 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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