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>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user