Files
clinicpro/assets/admin/pages/AppointmentsPage.test.tsx

369 lines
22 KiB
TypeScript
Raw Permalink 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 { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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 AppointmentsPage from './AppointmentsPage';
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1' } as any);
get.mockImplementation((url: string) => {
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 7, completed: 3, waiting: 2, cancelled: 1 } });
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
});
});
describe('AppointmentsPage — طرح نوبت‌ها', () => {
it('renders the title, the stats bar and the افزودن نوبت action', async () => {
renderWithProviders(<AppointmentsPage />);
expect(screen.getByText('نوبت‌ ها')).toBeInTheDocument();
expect(screen.getByText('کل نوبت های امروز')).toBeInTheDocument();
expect(await screen.findByText('۷')).toBeInTheDocument(); // total from stats
expect(screen.getByText('افزودن نوبت')).toBeInTheDocument();
});
it('timeline view shows the holiday message when the doctor has no slots', async () => {
renderWithProviders(<AppointmentsPage />);
// نمای پیش‌فرض زمانبندی است و پزشک (نقش doctor) از قبل انتخاب شده
expect(await screen.findByText('این روز تعطیل است')).toBeInTheDocument();
});
it('honors the ?date= query param (returns to the same day after edit)', async () => {
renderWithProviders(<AppointmentsPage />, { route: '/admin/appointments?date=2024-06-01' });
await screen.findByText('این روز تعطیل است');
const usedDate = get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('date=2024-06-01'));
expect(usedDate).toBe(true);
});
it('independent doctor profile does NOT show doctor tabs', async () => {
renderWithProviders(<AppointmentsPage />);
await screen.findByText('این روز تعطیل است');
expect(screen.queryByText('همه')).toBeNull();
// برچسب فیلتر سرویس دیده می‌شود (مطابق طرح)
expect(screen.getByText('سرویس مورد نظر را انتخاب کنید...')).toBeInTheDocument();
});
});
describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () => {
beforeEach(() => {
get.mockReset();
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic1' } as any);
get.mockImplementation((url: string) => {
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 5, completed: 1, waiting: 3, cancelled: 1 } });
// کلینیک هم از اندپوینت احرازشده می‌خواند؛ `has_schedule` مبنای ساختن تب است.
if (url.includes('/my/clinic-doctors') || url.includes('/clinic/doctor-list/')) {
return Promise.resolve({ success: true, data: { data: [
{ uuid: 'd1', name: 'دکتر محمدی', has_schedule: true },
{ uuid: 'd2', name: 'دکتر رضایی', has_schedule: true },
{ uuid: 'd3', name: 'دکتر بی‌برنامه', has_schedule: false },
] } });
}
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
// بازهٔ کاری منبع از تقویم خودش می‌آید، نه از برنامهٔ هفتگی پزشک.
if (url.includes('/day-slots')) {
const day = Math.floor(Date.now() / 1000) + 7 * 86400;
return Promise.resolve({ success: true, data: {
windows: [{ start: day, end: day + 4 * 3600, start_time: '08:00', end_time: '12:00' }],
empty_reason: null,
} });
}
// منابع زیر نظر پزشک‌اند؛ تب هر منبع فقط زیر ناظرِ خودش دیده می‌شود.
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [
{ uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
{ uuid: 'r-2', name: 'لیزر CO2', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
{ uuid: 'r-3', name: 'لیزر دکتر رضایی', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd2', name: 'دکتر رضایی' } },
{ uuid: 'r-4', name: 'کندلا ۲۰۲۳', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd3', name: 'دکتر بی‌برنامه' } },
] });
return Promise.resolve({ success: true, data: [] });
});
});
it('shows the doctor tabs (multi-doctor management) without the «همه» tab', async () => {
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument();
expect(screen.getByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.queryByText('همه')).toBeNull();
});
/**
* منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد، نه به
* پزشکی که پشتش ایستاده.
*/
it('فقط منابعِ تحت نظارت پزشکِ انتخاب‌شده تب می‌گیرند', async () => {
renderWithProviders(<AppointmentsPage />);
// پزشک اول (d1) خودکار انتخاب می‌شود، پس فقط منابع او دیده می‌شوند.
expect(await screen.findByText('لیزر CO2')).toBeInTheDocument();
expect(screen.getByText('اتاق ۱')).toBeInTheDocument();
expect(screen.queryByText('لیزر دکتر رضایی')).toBeNull();
});
/** تب فعال از URL خوانده می‌شود و فهرست با resource_uuid فیلتر می‌شود، نه doctor_uuid. */
it('تب منبع از URL خوانده می‌شود و فهرست را با resource_uuid می‌گیرد', async () => {
renderWithProviders(<AppointmentsPage />, { route: '/admin/appointments?resource=r-2' });
await screen.findByText('لیزر CO2');
const calls = get.mock.calls
.map((c: any[]) => c[0])
.filter((u: any) => typeof u === 'string' && u.includes('/my/appointments'));
expect(calls.some((u: string) => u.includes('resource_uuid=r-2'))).toBe(true);
expect(calls.some((u: string) => u.includes('doctor_uuid='))).toBe(false);
});
/**
* پزشکِ بدون برنامهٔ هفتگی هم تب می‌گیرد — با برچسب «بدون ساعت کاری» — چون منابعِ
* تحت نظرش تقویم مستقل دارند و پنهان‌کردنِ پزشک، نوبت‌دهی آن منابع را هم می‌بست.
*/
it('پزشک بدون برنامهٔ کاری تب می‌گیرد و برچسب می‌خورد', async () => {
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument();
expect(screen.getByText('دکتر بی‌برنامه')).toBeInTheDocument();
expect(screen.getByText('بدون ساعت کاری')).toBeInTheDocument();
});
it('پزشک بدون برنامهٔ کاری: پیام «ساعت کاری تنظیم نشده است» و منابعِ قابل نوبت‌دهی', async () => {
const user = userEvent.setup();
renderWithProviders(<AppointmentsPage />);
await user.click(await screen.findByRole('button', { name: /دکتر بی‌برنامه/ }));
expect(await screen.findByText('ساعت کاری تنظیم نشده است')).toBeInTheDocument();
// منبعِ همان پزشک هم در نوار تب‌ها و هم در میان‌بُرِ رزرو دیده می‌شود.
expect(screen.getAllByRole('button', { name: 'کندلا ۲۰۲۳' }).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeInTheDocument();
// برای پزشکِ بدون برنامه اسلات درخواست نمی‌شود.
expect(get.mock.calls.some(
(c: any[]) => typeof c[0] === 'string' && c[0].includes('appointment-slots') && c[0].includes('doctor_uuid=d3'),
)).toBe(false);
});
it('منبعِ پزشکِ بدون برنامه، تایم‌لاین تقویم خودش را می‌دهد', async () => {
const user = userEvent.setup();
renderWithProviders(<AppointmentsPage />, { route: '/admin/appointments?resource=r-4' });
await user.click(await screen.findByRole('button', { name: /دکتر بی‌برنامه/ }));
await user.click((await screen.findAllByRole('button', { name: 'کندلا ۲۰۲۳' }))[0]);
expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument();
expect(get.mock.calls.some(
(c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-4/day-slots'),
)).toBe(true);
});
/**
* باگ گزارش‌شده: انتخاب منبع، پزشک را پاک می‌کرد. افکتِ «انتخاب خودکار اولین پزشک»
* بلافاصله پزشکی را برمی‌گرداند، ولی **اولین** پزشک را — پس کاربری که روی پزشک دوم
* بود به پزشک اول می‌پرید و نوبت‌دهی همان پزشک را از دست می‌داد.
*/
it('انتخاب منبع، کاربر را روی همان پزشک نگه می‌دارد', async () => {
const user = userEvent.setup();
renderWithProviders(<AppointmentsPage />);
await user.click(await screen.findByRole('button', { name: 'دکتر رضایی' }));
const own = await screen.findByRole('button', { name: 'لیزر دکتر رضایی' });
await user.click(own);
// هنوز منابعِ پزشک دوم فهرست می‌شوند، نه منابع پزشک اول.
expect(await screen.findByRole('button', { name: 'لیزر دکتر رضایی' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'لیزر CO2' })).toBeNull();
});
/** برگشت از منبع به خودِ پزشک باید در همان نوار باشد، نه با کلیک روی تب بالایی. */
it('تب نخستِ نوار منابع، برگشت به نوبت‌دهی خود پزشک است', async () => {
const user = userEvent.setup();
renderWithProviders(<AppointmentsPage />);
await screen.findByText('دکتر محمدی');
await user.click(await screen.findByRole('button', { name: 'لیزر CO2' }));
expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'نوبت‌های خود پزشک' }));
expect(screen.queryByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeNull();
});
/**
* نمای منبع همان تایم‌لاینِ سرویسی است: بازهٔ کاری از `day-slots` خودِ منبع می‌آید
* و ردیف‌های خالی «افزودن نوبت سریع»اند — نه شبکهٔ اسلاتِ پزشک.
*/
it('نمای منبع، تایم‌لاین سرویسیِ تقویم خودِ منبع است', async () => {
const user = userEvent.setup();
renderWithProviders(<AppointmentsPage />);
await screen.findByText('دکتر محمدی');
await user.click(await screen.findByRole('button', { name: 'لیزر CO2' }));
expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument();
expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument();
// «ساعت کاری: …» بازهٔ منبع است؛ برچسبِ تبِ «بدون ساعت کاری» نباید اینجا بیفتد.
expect(screen.getByText(/^ساعت کاری:/)).toBeInTheDocument();
// بازهٔ کاری از تقویم منبع خوانده می‌شود، نه از اسلات‌های پزشک.
const askedResourceDay = get.mock.calls.some(
(c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/day-slots'),
);
expect(askedResourceDay).toBe(true);
expect(screen.queryByText('این روز تعطیل است')).toBeNull();
});
it('auto-selects the first doctor so the timeline loads its slots', async () => {
renderWithProviders(<AppointmentsPage />);
await screen.findByText('دکتر محمدی');
// اسلات‌ها برای اولین دکتر (d1) درخواست می‌شوند
await screen.findByText('این روز تعطیل است');
const calledSlotsForD1 = get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('appointment-slots') && c[0].includes('doctor_uuid=d1'));
expect(calledSlotsForD1).toBe(true);
});
});
describe('AppointmentsPage — منشی', () => {
/**
* منشی در هر دو ساختار باید تایم‌لاین ببیند. لیست پزشکانِ مجاز از اندپوینت
* احرازشدهٔ /my/clinic-doctors می‌آید (نه لیست عمومی کلینیک).
*/
function mockSecretary(
doctors: { uuid: string; name: string; has_schedule?: boolean }[],
scope: string | null,
resources: unknown[] = [],
) {
useAuthStore.setState({
primaryRole: 'secretary',
dbUuid: scope === 'clinic' ? 'clinic1' : 'doc1',
context: scope ? { type: scope, scope } : null,
availableContexts: [],
} as any);
get.mockImplementation((url?: string) => {
if (typeof url !== 'string') return Promise.resolve({ success: true, data: [] });
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 11, completed: 2, waiting: 7, cancelled: 2 } });
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: resources });
if (url.includes('/my/clinic-doctors')) return Promise.resolve({ success: true, data: { data: doctors } });
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => get.mockReset());
it('منشیِ پزشک مستقل: همان پزشک خودکار انتخاب و تایم‌لاین بارگذاری می‌شود', async () => {
mockSecretary([{ uuid: 'doc1', name: 'دکتر موسوی' }], null);
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText('این روز تعطیل است')).toBeInTheDocument();
expect(screen.queryByText('برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید')).toBeNull();
const calledSlots = get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('appointment-slots') && c[0].includes('doctor_uuid=doc1'));
expect(calledSlots).toBe(true);
});
it('منشیِ کلینیک: تب پزشکانِ تخصیص‌یافته و انتخاب خودکار اولی', async () => {
mockSecretary([{ uuid: 'd1', name: 'دکتر محمدی' }, { uuid: 'd2', name: 'دکتر رضایی' }], 'clinic');
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument();
expect(screen.getByText('دکتر رضایی')).toBeInTheDocument();
await screen.findByText('این روز تعطیل است');
const calledSlotsForD1 = get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('appointment-slots') && c[0].includes('doctor_uuid=d1'));
expect(calledSlotsForD1).toBe(true);
});
it('منشی هرگز از لیست عمومی کلینیک نمی‌خواند', async () => {
mockSecretary([{ uuid: 'doc1', name: 'دکتر موسوی' }], null);
renderWithProviders(<AppointmentsPage />);
await screen.findByText('این روز تعطیل است');
const usedPublicList = get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/clinic/doctor-list/'));
expect(usedPublicList).toBe(false);
});
/** منشی هم باید همان پیام و همان میان‌بُرِ منابع را ببیند، نه تایم‌لاین خالی. */
it('منشی: پزشکِ بدون برنامه پیام می‌گیرد و منابعش قابل رزروند', async () => {
mockSecretary([{ uuid: 'doc1', name: 'دکتر موسوی', has_schedule: false }], null, [
{ uuid: 'r-9', name: 'لیزر', type_name: 'دستگاه', supervisor: { uuid: 'doc1', name: 'دکتر موسوی' } },
]);
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText('ساعت کاری تنظیم نشده است')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeInTheDocument();
expect(screen.queryByText('این روز تعطیل است')).toBeNull();
});
});
/**
* پروفایل خودِ پزشک: تب پزشکان ندارد، پس تنها راهِ دانستنِ «برنامه ندارم» همان
* `has_schedule`ِ /my/clinic-doctors است.
*/
describe('AppointmentsPage — پروفایل خود پزشک بدون برنامهٔ کاری', () => {
function mockDoctor(hasSchedule: boolean, contexts: any[] = []) {
useAuthStore.setState({
primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1',
context: { type: 'doctor', db_uuid: 'doc1', name: 'مطب من', role: 'doctor' },
availableContexts: contexts,
} as any);
get.mockImplementation((url?: string) => {
if (typeof url !== 'string') return Promise.resolve({ success: true, data: [] });
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 0, completed: 0, waiting: 0, cancelled: 0 } });
if (url.includes('/my/clinic-doctors')) {
return Promise.resolve({ success: true, data: { data: [{ uuid: 'doc1', name: 'دکتر موسوی', has_schedule: hasSchedule }] } });
}
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'no_schedule' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [
{ uuid: 'r-7', name: 'کندلا', type_name: 'دستگاه لیزر', supervisor: { uuid: 'doc1', name: 'دکتر موسوی' } },
] });
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => get.mockReset());
it('پیام «ساعت کاری تنظیم نشده است» و منابعِ قابل رزرو را نشان می‌دهد', async () => {
mockDoctor(false);
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText('ساعت کاری تنظیم نشده است')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'کندلا' }).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeInTheDocument();
// لینک تنظیمات به صفحهٔ خودِ پزشک می‌رود، نه به تنظیمات کلینیک.
expect(screen.getByRole('link', { name: 'تنظیم ساعت کاری' })).toHaveAttribute('href', '/admin/appointment-settings');
});
it('برنامهٔ کاری که باشد، تایم‌لاین سرِ جایش می‌ماند', async () => {
mockDoctor(true);
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText('برنامهٔ نوبت‌دهی ثبت نشده')).toBeInTheDocument();
expect(screen.queryByText('ساعت کاری تنظیم نشده است')).toBeNull();
});
/** منابعِ کلینیک در محیط کلینیک‌اند؛ «نیست» با «جای دیگر است» اشتباه نشود. */
it('در مطب شخصی، محیط‌های کلینیکیِ دیگر یادآوری می‌شوند', async () => {
mockDoctor(false, [
{ type: 'doctor', db_uuid: 'doc1', name: 'مطب من', role: 'doctor' },
{ type: 'clinic', db_uuid: 'cl-1', name: 'مدیسا', role: 'doctor' },
]);
get.mockImplementation((url?: string) => {
if (typeof url !== 'string') return Promise.resolve({ success: true, data: [] });
if (url.includes('/my/clinic-doctors')) return Promise.resolve({ success: true, data: { data: [{ uuid: 'doc1', name: 'دکتر موسوی', has_schedule: false }] } });
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 0, completed: 0, waiting: 0, cancelled: 0 } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] }); // منبعی در محیط شخصی نیست
});
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText(/مدیسا/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'تغییر محیط کاری' })).toHaveAttribute('href', '/admin/select-context');
});
});