Files
clinicpro/assets/admin/pages/AppointmentsPage.test.tsx
T
hamedandClaude Opus 5 732fbd462f fix(appointments): a resource is a view inside its doctor, and never slot-based
Two reported bugs, one root cause: resource tabs were built as a rival selection
to the doctor rather than a narrower view within them.

Selecting a resource cleared the doctor. The auto-select effect then quietly put
the *first* doctor back, so anyone working under the second doctor was thrown to
the first and lost that doctor's own booking. Selecting a resource now leaves the
doctor alone; only picking a doctor clears the resource.

A resource tab also still rendered the doctor's slot timeline, just with no data.
Resources have no slotted weekly schedule — their calendar comes from service
duration and real occupancy — so showing a slot grid promises times the booking
engine does not recognise. The resource tab now renders its own panel: that day's
appointments on the resource plus a service booking entry point.

Both regressions are pinned by tests, and both were checked by reverting each fix
in turn. The first attempt at the doctor-retention test passed even with the bug
restored, because the auto-select effect masked it; it was rewritten to use the
second doctor, where the bounce is observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:58:13 +03:30

221 lines
13 KiB
TypeScript
Raw 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('/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: 'دکتر رضایی' } },
] });
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.queryByText('دکتر بی‌برنامه')).toBeNull();
});
/**
* باگ گزارش‌شده: انتخاب منبع، پزشک را پاک می‌کرد. افکتِ «انتخاب خودکار اولین پزشک»
* بلافاصله پزشکی را برمی‌گرداند، ولی **اولین** پزشک را — پس کاربری که روی پزشک دوم
* بود به پزشک اول می‌پرید و نوبت‌دهی همان پزشک را از دست می‌داد.
*/
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();
expect(screen.getByText('افزودن نوبت سرویس')).toBeInTheDocument();
// ورودی اسلاتی نباید باشد.
expect(screen.queryByText('افزودن نوبت سریع')).toBeNull();
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 }[], scope: string | null) {
useAuthStore.setState({
primaryRole: 'secretary',
dbUuid: scope === 'clinic' ? 'clinic1' : 'doc1',
context: scope ? { type: scope, scope } : null,
} 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('/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);
});
});