import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } from '@testing-library/react'; import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, ApiError: class extends Error {}, })); import { api } from '../lib/api'; import AppointmentEditPage from './AppointmentEditPage'; const get = api.get as ReturnType; const post = api.post as ReturnType; const patch = api.patch as ReturnType; const slotStart = Math.floor(new Date('2026-08-01T15:00').getTime() / 1000); const slotEnd = Math.floor(new Date('2026-08-01T15:30').getTime() / 1000); const newStart = Math.floor(new Date('2026-08-01T16:00').getTime() / 1000); /** * `bookingMode` روی محلِ خودِ نوبت است، پس پاسخ `appointment-booking-services` را با * همان `doctor_uuid` و بدون `clinic_uuid` (مطب شخصی) می‌دهیم. */ function mockApi(opts: { mode: 'slot' | 'service'; isReserve?: boolean; serviceItems?: any[] }) { get.mockImplementation((url: string) => { if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: { uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4, doctor: { uuid: 'doc1', name: 'دکتر تست' }, clinic_uuid: null, is_reserve: opts.isReserve ?? false, service_total_minutes: opts.mode === 'service' ? 30 : null, service_buffer_minutes: opts.mode === 'service' ? 10 : null, service_section: { uuid: 'sec1', name: 'زیبایی' }, service_item: { uuid: 'it1', name: 'لیزر' }, service_items: opts.serviceItems ?? [{ uuid: 'it1', name: 'لیزر' }], staff: { uuid: 'st1', full_name: 'سحر ایمانی' }, } } }); if (url.startsWith('/api/v1/appointment-booking-services/')) { return Promise.resolve({ success: true, data: { booking_mode: opts.mode, buffer_minutes: 10, services: [ { uuid: 'it1', name: 'لیزر', duration_minutes: 30, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } }, { uuid: 'it2', name: 'تزریق', duration_minutes: 15, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } }, ], } }); } if (url.startsWith('/api/v1/appointment-service-slots')) { return Promise.resolve({ success: true, data: { total_duration_minutes: 30, buffer_minutes: 10, start_times: [{ start: newStart, end: newStart + 1800, start_time: '16:00' }], } }); } if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] }); if (url.startsWith('/api/v1/service-items/')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر' }] }); if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] }); return Promise.resolve({ success: true, data: [] }); }); } function renderEdit() { return renderWithProviders( } />, { route: '/admin/appointments/ap1/edit' }, ); } beforeEach(() => { get.mockReset(); post.mockReset(); patch.mockReset(); post.mockResolvedValue({ success: true, data: {} }); patch.mockResolvedValue({ success: true, data: {} }); }); describe('AppointmentEditPage — حالت نوبت‌دهی سرویسی', () => { it('ورودی دستی ساعت را پنهان می‌کند و انتخابگر سرویس را نشان می‌دهد', async () => { mockApi({ mode: 'service' }); renderEdit(); await screen.findByText('مشخصات سرویس:'); // انتخابگر سرویس هم برچسب «بخش» دارد؛ ظاهر شدن دو تا نشانهٔ mount شدنش است. await waitFor(() => expect(screen.getAllByText('بخش')).toHaveLength(2)); expect(screen.queryByLabelText('ساعت شروع')).not.toBeInTheDocument(); expect(screen.queryByLabelText('ساعت پایان')).not.toBeInTheDocument(); }); it('دکمهٔ ثبت تا انتخاب زمان غیرفعال است و راهنمای فارسی دارد', async () => { mockApi({ mode: 'service' }); renderEdit(); await screen.findByText('مشخصات سرویس:'); expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).toBeDisabled(); expect(screen.getByText('برای ثبت، سرویس و سپس یکی از زمان‌های خالی را انتخاب کنید.')).toBeInTheDocument(); }); it('exclude_appointment_uuid را به درخواست اسلات‌ها می‌دهد', async () => { mockApi({ mode: 'service' }); renderEdit(); await screen.findByText('مشخصات سرویس:'); await waitFor(() => { const calls = get.mock.calls.map((c) => String(c[0])); expect(calls.some((u) => u.includes('appointment-service-slots') && u.includes('exclude_appointment_uuid=ap1'))).toBe(true); }); }); it('ثبت، service-reschedule را بدون هیچ مدتی صدا می‌زند', async () => { mockApi({ mode: 'service' }); renderEdit(); await screen.findByText('مشخصات سرویس:'); fireEvent.click(await screen.findByRole('button', { name: '16:00' })); fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); await waitFor(() => expect(post).toHaveBeenCalledWith( '/api/v1/appointment/ap1/service-reschedule', expect.objectContaining({ start: newStart, service_item_uuids: ['it1'] }), )); const body = post.mock.calls[0][1] as Record; expect(body).not.toHaveProperty('slot_end'); expect(body).not.toHaveProperty('total_duration_minutes'); }); it('PATCH بعدی زمان و نسخه را نمی‌فرستد (نسخه یک قدم جلو رفته)', async () => { mockApi({ mode: 'service' }); renderEdit(); await screen.findByText('مشخصات سرویس:'); fireEvent.click(await screen.findByRole('button', { name: '16:00' })); fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); await waitFor(() => expect(patch).toHaveBeenCalled()); const body = patch.mock.calls[0][1] as Record; expect(body).not.toHaveProperty('slot_start'); expect(body).not.toHaveProperty('slot_end'); expect(body).not.toHaveProperty('version'); expect(body).toHaveProperty('deposit_required'); }); it('سرویسِ غیرفعالِ نوبت را با هشدار فارسی گزارش می‌کند', async () => { mockApi({ mode: 'service', serviceItems: [{ uuid: 'it1', name: 'لیزر' }, { uuid: 'gone', name: 'حذف‌شده' }] }); renderEdit(); await screen.findByText('مشخصات سرویس:'); expect(await screen.findByText(/۱ سرویس این نوبت دیگر برای نوبت‌دهی فعال نیست|1 سرویس این نوبت دیگر برای نوبت‌دهی فعال نیست/)) .toBeInTheDocument(); }); }); describe('AppointmentEditPage — خط سرخ: حالت اسلاتی و رزرو', () => { it('حالت اسلاتی سه فیلد ساعت را حفظ می‌کند و انتخابگر ندارد', async () => { mockApi({ mode: 'slot' }); renderEdit(); await screen.findByText('مشخصات سرویس:'); expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00'); expect(screen.getByLabelText('ساعت پایان')).toBeInTheDocument(); expect(screen.queryByText('زمان‌های خالی پیشنهادی')).not.toBeInTheDocument(); }); it('حالت اسلاتی همان PATCH قبلی با version را می‌فرستد', async () => { mockApi({ mode: 'slot' }); renderEdit(); await screen.findByText('مشخصات سرویس:'); // پیش از کلیک باید هیدریت شده باشد، وگرنه toEpoch('','') مقدار NaN می‌سازد. await waitFor(() => expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00')); fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ slot_start: slotStart, slot_end: slotEnd, version: 4, }))); expect(post).not.toHaveBeenCalled(); }); it('نوبت رزرو در محل سرویسی هم انتخابگر زمان نمی‌گیرد', async () => { mockApi({ mode: 'service', isReserve: true }); renderEdit(); await screen.findByText('مشخصات سرویس:'); expect(screen.getByLabelText('ساعت شروع')).toBeInTheDocument(); expect(screen.queryByText('زمان‌های خالی پیشنهادی')).not.toBeInTheDocument(); }); });