Files
clinicpro/assets/admin/components/ui/DoctorPermissionsModal.test.tsx
T
hamedandClaude Opus 5 ddd5f8f75a feat(permissions): render both permission forms from the catalog, fix borrowed gates
The three hardcoded resource lists in the admin panel are gone. MySecretariesPage,
SecretariesPage and DoctorPermissionsModal now render from
GET /api/v1/permission-catalog, so a resource added to the backend registry shows
up in all of them with no frontend change. Each has a test that proves exactly
that by adding a resource to the mock and asserting it renders.

SecretaryPermissions was an interface with a field per resource, which made
"dynamic" impossible in TypeScript — every new resource would have been a compile
error. It is now an open map. Only two files consumed it.

The borrowed gates are corrected:
- five resource pages moved off appointment_settings onto their own 'resources'
- treatment-cases moved off appointments onto 'treatment'
- service-categories moved onto 'services', which is what ServiceCatalogController
  actually manages (categories, item groups, service relations) — not resources

TreatmentCaseController had no permission gate at all, only IS_AUTHENTICATED_FULLY,
so any secretary could read and edit treatment cases. All seven of its actions are
now gated on treatment view/update.

ResourcePermissionTrait takes the resource from an overridable method instead of
hardcoding appointment_settings. HolidayController overrides it back, since the
holidays page really is appointment settings. The booking gate keeps its
appointments.view fallback so a secretary who may book is not blocked by a
resource-config permission.

Defaults were picked to preserve today's effective access, so no role gains or
loses a page from this move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:11:05 +03:30

103 lines
4.0 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } 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 {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../../lib/api';
import DoctorPermissionsModal from './DoctorPermissionsModal';
const get = api.get as ReturnType<typeof vi.fn>;
const resource = (key: string, label: string, actions: [string, string][]) => ({
key,
label,
clinic_only: false,
actions: actions.map(([k, l]) => ({ key: k, label: l })),
});
const BASE_CATALOG = [
resource('appointments', 'مدیریت نوبت‌ها', [['view', 'مشاهده'], ['create', 'ایجاد']]),
resource('patients', 'پرونده بیماران', [['view', 'مشاهده'], ['delete', 'حذف']]),
resource('services', 'خدمات و تعرفه‌ها', [['view', 'مشاهده']]),
];
const permissionPayload = {
uuid: 'p1',
clinic_uuid: 'c1',
doctor_uuid: 'd1',
doctor_name: 'دکتر تست',
active: true,
permissions: { version: 1, resources: { appointments: { view: true, create: false } } },
};
function mockApi(catalog: unknown[]) {
get.mockImplementation((url: string) => {
if (url.includes('permission-catalog')) {
return Promise.resolve({ success: true, data: { version: 1, resources: catalog } });
}
return Promise.resolve({ success: true, data: permissionPayload });
});
}
function render(catalog: unknown[] = BASE_CATALOG) {
mockApi(catalog);
return renderWithProviders(
<DoctorPermissionsModal clinicUuid="c1" doctorUuid="d1" doctorName="دکتر تست" onClose={() => {}} />,
);
}
describe('DoctorPermissionsModal', () => {
beforeEach(() => vi.clearAllMocks());
it('renders one row per catalog resource', async () => {
render();
expect(await screen.findByText('مدیریت نوبت‌ها')).toBeInTheDocument();
expect(screen.getByText('پرونده بیماران')).toBeInTheDocument();
expect(screen.getByText('خدمات و تعرفه‌ها')).toBeInTheDocument();
});
/**
* قلبِ «داینامیک بودن»: منبع تازه فقط به mock اضافه می‌شود و هیچ خطی از کد
* کامپوننت عوض نمی‌شود. اگر این تست بشکند یعنی فهرست دوباره هاردکد شده.
*/
it('shows a resource added to the catalog with no code change', async () => {
render([...BASE_CATALOG, resource('brand_new_page', 'صفحهٔ کاملاً تازه', [['view', 'مشاهده']])]);
expect(await screen.findByText('صفحهٔ کاملاً تازه')).toBeInTheDocument();
});
it('renders a switch only for actions the resource actually has', async () => {
render();
// patients در کاتالوگ view و delete دارد، ولی create ندارد.
expect(await screen.findByLabelText('پرونده بیماران — حذف')).toBeInTheDocument();
expect(screen.queryByLabelText('پرونده بیماران — ایجاد')).not.toBeInTheDocument();
});
it('reflects stored values and defaults missing ones to off', async () => {
render();
await waitFor(() => expect(screen.getByLabelText('مدیریت نوبت‌ها — مشاهده')).toBeChecked());
expect(screen.getByLabelText('مدیریت نوبت‌ها — ایجاد')).not.toBeChecked();
// services اصلاً در JSONِ ذخیره‌شده نیست
expect(screen.getByLabelText('خدمات و تعرفه‌ها — مشاهده')).not.toBeChecked();
});
it('shows loading rather than an empty table while the catalog is in flight', () => {
get.mockImplementation(() => new Promise(() => {}));
renderWithProviders(
<DoctorPermissionsModal clinicUuid="c1" doctorUuid="d1" doctorName="دکتر تست" onClose={() => {}} />,
);
expect(screen.getByText('در حال بارگذاری...')).toBeInTheDocument();
});
});