262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
|
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 { useAuthStore } from '../../stores/authStore';
|
|
import NewAppointmentModal from './NewAppointmentModal';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const post = api.post as ReturnType<typeof vi.fn>;
|
|
|
|
const START = 1_900_000_000;
|
|
|
|
const slot = {
|
|
start: 0, end: 0, start_time: '', end_time: '',
|
|
doctor_uuid: 'doc1', doctor_name: 'مینا یوسفی',
|
|
};
|
|
|
|
const resource = { uuid: 'r-2', name: 'لیزر CO2' };
|
|
|
|
const services = [
|
|
{ uuid: 's-1', name: 'کرایوتراپی', duration_minutes: 25, price_rials: 1_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
|
|
{ uuid: 's-2', name: 'RF فرکشنال', duration_minutes: 50, price_rials: 2_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
|
|
];
|
|
|
|
/** بیمارِ یافتشده تا فرم معتبر شود؛ زمانهای خالی از تقویم منبع. */
|
|
function mockApi() {
|
|
get.mockImplementation((url: string) => {
|
|
if (url.includes('patient-lookup')) {
|
|
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
|
|
}
|
|
if (url === '/api/v1/staff') {
|
|
return Promise.resolve({ success: true, data: [{ uuid: 'st-1', full_name: 'پرسنل یک' }] });
|
|
}
|
|
if (url.includes('/treatment-protocol')) {
|
|
return Promise.resolve({ success: true, data: { staff: [{ uuid: 'st-1', name: 'پرسنل یک' }] } });
|
|
}
|
|
if (url.includes('/service-slots')) {
|
|
return Promise.resolve({ success: true, data: {
|
|
total_duration_minutes: 25,
|
|
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
|
|
} });
|
|
}
|
|
return Promise.resolve({ success: true, data: {} });
|
|
});
|
|
}
|
|
|
|
/** انتخاب بخش → سرویس → زمان، همان مسیرِ مودالِ طرح. */
|
|
async function pickServiceAndTime() {
|
|
// مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش میرویم.
|
|
fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' });
|
|
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
|
|
// ردیف سرویس یک checkbox است نه دکمه: انتخابش حالت دارد و باید برای screen reader
|
|
// «انتخابشده/نشده» اعلام شود.
|
|
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
|
|
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
|
|
}
|
|
|
|
/** فرم ویزارد شد: هر «مرحلهٔ بعد» یک گام جلو میبرد. */
|
|
const nextStep = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
|
|
|
|
/** خدمت و زمان را انتخاب میکند و به مرحلهٔ «بیمار» میرود. */
|
|
async function pickServiceAndTimeThenNext() {
|
|
await pickServiceAndTime();
|
|
nextStep();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
get.mockReset();
|
|
post.mockReset();
|
|
useAuthStore.setState({ primaryRole: 'clinic' } as any);
|
|
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
|
|
mockApi();
|
|
});
|
|
|
|
describe('مودال ثبت نوبتِ منبع', () => {
|
|
it('نام منبع و پزشک ناظر بالای فرم میآید و زمانها از تقویم منبع خوانده میشوند', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(screen.getByText('لیزر CO2')).toBeInTheDocument();
|
|
expect(screen.getByText('مینا یوسفی')).toBeInTheDocument();
|
|
|
|
await pickServiceAndTime();
|
|
|
|
await waitFor(() => expect(
|
|
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/service-slots')),
|
|
).toBe(true));
|
|
});
|
|
|
|
it('ثبت، نوبت را با resource_uuid و مدتِ سرویسها میفرستد', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTimeThenNext();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
nextStep();
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
|
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
const [url, body] = post.mock.calls[0];
|
|
expect(url).toBe('/api/v1/my/appointment');
|
|
expect(body).toMatchObject({
|
|
resource_uuid: 'r-2',
|
|
duration_from_services: true,
|
|
service_item_uuids: ['s-1'],
|
|
service_durations: { 's-1': 25 },
|
|
slot_start: START,
|
|
patient_national_code: '0012345678',
|
|
});
|
|
});
|
|
|
|
it('بدون انتخاب سرویس و زمان، از مرحلهٔ اول رد نمیشود', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
// مرحلهٔ بیمار هنوز ساخته نشده، پس فیلد جستجو در DOM نیست.
|
|
expect(screen.queryByPlaceholderText('کد ملی ۱۰ رقمی')).toBeNull();
|
|
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
|
|
});
|
|
|
|
it('دلیلِ غیرفعال بودنِ ثبت را میگوید و با پیشرفتِ فرم عوض میشود', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(await screen.findByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
|
|
|
|
// مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش میرویم.
|
|
fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' });
|
|
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
|
|
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
|
|
|
|
// سرویس هست، زمان نه — پیام باید مرحلهٔ بعد را نشان دهد نه همان قبلی.
|
|
expect(await screen.findByText(/ساعت شروع را انتخاب کنید/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
|
|
nextStep();
|
|
expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument();
|
|
});
|
|
|
|
/** تاریخ در حالت انتخابگر یک فیلدِ قابل ویرایش است، نه متنِ ثابتِ هدر. */
|
|
it('تاریخ نوبت بهصورت جلالی و قابل ویرایش میآید', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(screen.getByLabelText('تاریخ نوبت')).toBeInTheDocument();
|
|
expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
|
|
// چیپ هدر دیگر تاریخ را تکرار نمیکند.
|
|
expect(screen.queryByText('تاریخ:')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('معیار جستجو یک seg با حالتِ اعلامشده است', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTimeThenNext();
|
|
|
|
const national = screen.getByRole('button', { name: 'کد ملی' });
|
|
const mobile = screen.getByRole('button', { name: 'شماره موبایل' });
|
|
expect(national).toHaveAttribute('aria-pressed', 'true');
|
|
expect(mobile).toHaveAttribute('aria-pressed', 'false');
|
|
|
|
fireEvent.click(mobile);
|
|
expect(screen.getByRole('button', { name: 'شماره موبایل' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument();
|
|
});
|
|
|
|
/** آنچه در «طول درمان» سرویس تعریف شده نباید دوباره از منشی پرسیده شود. */
|
|
it('پرسنلِ پروتکل سرویس را پیشفرض میفرستد', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTime();
|
|
// پرسنل در همان مرحلهٔ «خدمت و زمان» است.
|
|
await waitFor(() => expect(screen.getByLabelText(/پرسنل/)).toBeInTheDocument());
|
|
nextStep();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
nextStep();
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
expect(post.mock.calls[0][1]).toMatchObject({ staff_uuid: 'st-1' });
|
|
});
|
|
|
|
/** پروتکل بدون پرسنل نباید فرم را قفل کند. */
|
|
it('پروتکل بدون پرسنل، فیلد را خالی میگذارد و ثبت همچنان ممکن است', async () => {
|
|
get.mockImplementation((url: string) => {
|
|
if (url.includes('/treatment-protocol')) return Promise.resolve({ success: true, data: null });
|
|
if (url.includes('patient-lookup')) {
|
|
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
|
|
}
|
|
if (url.includes('/service-slots')) {
|
|
return Promise.resolve({ success: true, data: {
|
|
total_duration_minutes: 25,
|
|
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
|
|
} });
|
|
}
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTimeThenNext();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
nextStep();
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid');
|
|
});
|
|
|
|
/**
|
|
* وقتِ پیشنهادی همیشه در دسترس نیست؛ کاربر باید بتواند بدون بستن فرم روز دیگری
|
|
* بگیرد و زمانها برای همان روز دوباره خوانده شوند.
|
|
*/
|
|
it('تغییر تاریخ، زمانهای خالی را برای روز تازه میخواند', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTime();
|
|
await waitFor(() => expect(
|
|
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('date=2026-08-04')),
|
|
).toBe(true));
|
|
|
|
// `PersianDateInput` تریگرش input نیست؛ با نام دسترسپذیرش پیدایش میکنیم.
|
|
expect(screen.getByLabelText('تاریخ نوبت')).toBeInTheDocument();
|
|
});
|
|
|
|
it('منبعِ بدون سرویس، پیام راهنما میدهد نه فهرست خالی', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument();
|
|
});
|
|
});
|