feat: implement OTP login flow and enhance role-based access control

- Added OTP login functionality in driver.mjs to handle user authentication with a fixed code in dev environment.
- Enhanced RoleRoute component in App.tsx to support clinic-scoped doctor roles and permissions.
- Updated ClinicDoctorsManager component to include pagination and search functionality for better user experience.
- Refactored tests for ClinicDoctorsManager to cover new features and ensure proper API mocking.
- Adjusted permissions in settingsMenu.ts and PracticeDomainSettingsPage.tsx to align with updated backend requirements.
- Created RoleRoute.test.tsx to validate role-based access logic for different user scenarios.
This commit is contained in:
hamed
2026-08-09 12:41:11 +03:30
parent cfeb447645
commit 60ccd5cc1d
8 changed files with 515 additions and 121 deletions
@@ -1,48 +1,129 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
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 ClinicDoctorsManager from './ClinicDoctorsManager';
const get = api.get as ReturnType<typeof vi.fn>;
vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }));
beforeEach(() => {
get.mockReset();
get.mockImplementation((url: string) => {
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
{ id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
] });
if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
{ uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
status: 'pending', token_used: false, invited_at: 1, expires_at: 9_999_999_999,
responded_at: null, doctor: null },
], meta: { totalRecords: 1, totalPages: 1, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
const DOCTOR = {
id: '1',
uuid: 'doc-1',
name: 'پزشک دعوت‌شده',
gender: null,
degree: null,
img: [],
specialties: [],
active: false, // نوبت‌دهی آنلاینِ خودِ پزشک خاموش است
};
/** پاسخ‌ها بر اساس مسیر تفکیک می‌شوند، چون سه کوئری موازی می‌روند. */
function mockApi({ doctors = [DOCTOR], total = 1, permissions = [] as any[], failDoctors = false } = {}) {
(api.get as any).mockImplementation((url: string) => {
if (url.includes('/doctor-permissions')) return Promise.resolve({ data: permissions });
if (url.includes('/invitations')) return Promise.resolve({ data: [] });
if (url.includes('/doctor-list/')) {
return failDoctors
? Promise.reject(new Error('boom'))
: Promise.resolve({ data: doctors, meta: { totalRecords: total, totalPages: 1, currentPage: 1 } });
}
return Promise.resolve({ data: [] });
});
});
}
describe('ClinicDoctorsManager', () => {
it('lists clinic doctors and shows management controls by default', async () => {
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" />, { route: '/admin/settings/clinic-doctors' });
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.getByText('قلب')).toBeInTheDocument();
// manager controls
expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
beforeEach(() => vi.clearAllMocks());
it('صفحه و سقف را به سرور می‌فرستد — وگرنه backend سر ۱۰ پزشک بی‌صدا می‌برد', async () => {
mockApi();
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
await waitFor(() => expect(api.get).toHaveBeenCalled());
const listCall = (api.get as any).mock.calls.find((c: any[]) => c[0].includes('/doctor-list/'));
expect(listCall[0]).toContain('page=1');
expect(listCall[0]).toContain('limit=10');
});
it('hides every mutating control when readOnly', async () => {
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" readOnly />, { route: '/admin/settings/clinic-doctors' });
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
it('شمارندهٔ تب از meta می‌آید، نه از طول صفحهٔ جاری', async () => {
mockApi({ doctors: [DOCTOR], total: 25 });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText(/پزشکان \(۲۵\)/)).toBeInTheDocument();
});
it('«نوبت‌دهی آنلاین» و «دسترسی در کلینیک» دو ستون جدا هستند', async () => {
mockApi();
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('دسترسی در کلینیک')).toBeInTheDocument();
expect(screen.getByText('نوبت‌دهی آنلاین')).toBeInTheDocument();
});
it('شمار مجوزها زیر همان بجِ دسترسی می‌آید، نه در ستون جدا', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: true,
permissions: { resources: { appointments: { view: true, create: false } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
expect(screen.queryByText('مجوزها')).not.toBeInTheDocument();
});
it('پزشکِ بدون ردیف مجوز، «پیش‌فرض» و دسترسی فعال می‌گیرد', async () => {
mockApi({ permissions: [] });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('پیش‌فرض')).toBeInTheDocument();
});
it('ردیف مجوزِ خاموش، «غیرفعال» نشان می‌دهد و شمار مجوزها را می‌آورد', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: false,
permissions: { resources: { appointments: { view: true, create: false } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
// دو «غیرفعال»: یکی دسترسیِ کلینیک، یکی نوبت‌دهی آنلاین. پیش از این یک بج جای
// هر دو می‌نشست و همین ابهام، پزشکِ سالم را «غیرفعال» نشان می‌داد.
expect(screen.getAllByText('غیرفعال')).toHaveLength(2);
});
it('دسترسیِ فعال با نوبت‌دهیِ خاموش قاطی نمی‌شود', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: true,
permissions: { resources: { appointments: { view: true } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
// دسترسی فعال است…
expect(await screen.findByText('فعال')).toBeInTheDocument();
// …ولی نوبت‌دهی آنلاینِ خودِ پزشک همچنان خاموش.
expect(screen.getByText('غیرفعال')).toBeInTheDocument();
});
it('خطای فهرست، پیام خطا می‌دهد نه حالت خالی', async () => {
mockApi({ failDoctors: true });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('فهرست پزشکان بارگذاری نشد')).toBeInTheDocument();
expect(screen.queryByText('هیچ پزشکی به این کلینیک متصل نیست')).not.toBeInTheDocument();
});
it('فهرست خالی، پیام خالی می‌دهد', async () => {
mockApi({ doctors: [], total: 0 });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('هیچ پزشکی به این کلینیک متصل نیست')).toBeInTheDocument();
});
});