Files
clinicpro/assets/admin/components/appointments/ResourceBookingModal.test.tsx
T
hamedandClaude Opus 5 c4099f0f63 feat(admin): default the booking staff from the service's protocol, and call it پرسنل
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>
2026-08-07 16:28:13 +03:30

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