Resources now get their own tabs on the appointments page, alongside doctors. An appointment on "Laser CO2" belongs to the device, not to whichever doctor happens to stand behind it, so selecting a resource tab replaces the doctor filter instead of stacking on top of it. GET /api/v1/my/appointments gains an optional resource_uuid filter and returns a `resource` object per row. The join is a leftJoin on purpose: appointments created before the resource-first model have no resource and must not drop out of the list. The resource tab lives in the URL so Back and refresh restore the same view, per the list-state rule in CLAUDE.md. The doctor tab is still useState; moving it is a separate refactor and was left untouched. Verified against the running app: filtering by a resource returns only its appointments, a resource from another tenant returns an empty list (TenantFilter, 200 not 403), and legacy rows still list with resource: null. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170 lines
9.3 KiB
TypeScript
170 lines
9.3 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { screen } 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 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 } });
|
||
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: { data: [
|
||
{ uuid: 'd1', name: 'دکتر محمدی' }, { uuid: 'd2', name: 'دکتر رضایی' },
|
||
] } });
|
||
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: 'اتاق درمان' },
|
||
{ uuid: 'r-2', name: 'لیزر CO2', type_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 />);
|
||
expect(await screen.findByText('لیزر CO2')).toBeInTheDocument();
|
||
expect(screen.getByText('اتاق ۱')).toBeInTheDocument();
|
||
});
|
||
|
||
/** تب فعال از 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('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);
|
||
});
|
||
});
|