The staff field started empty even when the service's treatment protocol already named who may perform it — a decision made once in the service settings and then asked again on every booking. The modal now reads that protocol and preselects its first staff member, but only until the user touches the field; otherwise a manual choice would be wiped on the next service change. A protocol with no staff leaves it empty and does not block submission. Renames the four user-facing 'اپراتور' strings to 'پرسنل', matching the record in /admin/staff that they all refer to. ResourcesPage keeps the word: there it names a kind of bookable resource (doctor, operator, room, device), not a ClinicStaff row. Drops a test whose premise the default invalidated; the two new ones cover both sides — protocol with staff sends staff_uuid, protocol without staff sends none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
11 KiB
TypeScript
228 lines
11 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' }));
|
|
}
|
|
|
|
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 pickServiceAndTime();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
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('بدون انتخاب سرویس و زمان، ثبت غیرفعال میماند', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
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' }));
|
|
expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('تاریخ نوبت بهصورت جلالی بالای فرم میآید', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
// بدون این، کاربر روزِ در حال رزرو را هیچجای مودال نمیدید.
|
|
expect(screen.getByText('تاریخ:')).toBeInTheDocument();
|
|
expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
|
|
});
|
|
|
|
it('معیار جستجو یک seg با حالتِ اعلامشده است', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
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();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
await waitFor(() => expect(screen.getByLabelText(/پرسنل/)).toBeInTheDocument());
|
|
|
|
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 pickServiceAndTime();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid');
|
|
});
|
|
|
|
it('منبعِ بدون سرویس، پیام راهنما میدهد نه فهرست خالی', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument();
|
|
});
|
|
});
|