Files
clinicpro/assets/admin/pages/AppointmentsPage.test.tsx
T
hamedandClaude Opus 5 3e3a2482fc feat(appointments): per-resource tabs backed by a resource_uuid list filter
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>
2026-08-03 11:31:37 +03:30

170 lines
9.3 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 { 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);
});
});