- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
225 lines
10 KiB
TypeScript
225 lines
10 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, fireEvent } from '@testing-library/react';
|
|
import { Routes, Route } from 'react-router';
|
|
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 { useAuthStore } from '../stores/authStore';
|
|
import ServiceDetailPage from './ServiceDetailPage';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
|
|
const ITEM = {
|
|
uuid: 'it1',
|
|
name: 'سرم ۵۰۰cc',
|
|
section_uuid: 'sec1',
|
|
section_name: 'تزریقات',
|
|
price_rials: 8_500_000,
|
|
active: true,
|
|
bookable: true,
|
|
duration_minutes: 30,
|
|
insurance_covered: true,
|
|
staff: { uuid: 'st1', full_name: 'مریم امینی' },
|
|
staff_members: [{ uuid: 'st1', full_name: 'مریم امینی' }],
|
|
created_at: 1_700_000_000,
|
|
updated_at: 1_800_000_000,
|
|
};
|
|
|
|
const mockApi = (item: unknown = ITEM, notFound = false) => {
|
|
get.mockImplementation((url: string) => {
|
|
if (url.includes('/subscription/my')) return Promise.resolve({ success: true, data: {
|
|
subscription: null, used_trial: false,
|
|
effective_plan: { name: 'professional', level: 2, max_secretaries: 10, features: { services: true } },
|
|
} });
|
|
if (url.includes('/payment/config')) return Promise.resolve({ success: true, data: { test_mode: true, gateways: [] } });
|
|
if (url.includes('/audit-logs')) return Promise.resolve({ success: true, data: [
|
|
{ uuid: 'l1', field: 'price_rials', operation: 'update', old_value: '7000000', new_value: '8500000', actor_name: 'دکتر تست', created_at: 1_800_000_000 },
|
|
{ uuid: 'l2', field: 'name', operation: 'create', old_value: null, new_value: 'سرم ۵۰۰cc', actor_name: 'دکتر تست', created_at: 1_700_000_000 },
|
|
] });
|
|
if (url.includes('/service-item/')) {
|
|
return notFound ? Promise.reject(new Error('یافت نشد')) : Promise.resolve({ success: true, data: item });
|
|
}
|
|
if (url.includes('/tenant-insurances')) return Promise.resolve({ data: { data: [
|
|
{ uuid: 'ins1', insurance_name: 'بیمه ایران', insurance_kind: 'basic', coverage_percent: 70 },
|
|
] } });
|
|
if (url.includes('/inventory-items')) return Promise.resolve({ success: true, data: { items: [
|
|
{ uuid: 'inv1', name: 'گاز استریل', unit: 'عدد', price: 50_000, stock: 100 },
|
|
] } });
|
|
if (url.includes('/inventory-packages')) return Promise.resolve({ success: true, data: [
|
|
{
|
|
uuid: 'pkg1', title: 'پکیج سرم', total: 1_200_000, available: true,
|
|
items: [{ itemUuid: 'i1', name: 'سرم رینگر', unit: 'عدد', price: 600_000, amount: 2 }],
|
|
},
|
|
] });
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
};
|
|
|
|
beforeEach(() => {
|
|
useAuthStore.setState({ primaryRole: 'doctor' });
|
|
get.mockReset();
|
|
mockApi();
|
|
});
|
|
|
|
// صفحه uuid را از useParams میگیرد، پس به یک Route واقعی نیاز دارد.
|
|
const render = (route = '/admin/clinic-services/it1') =>
|
|
renderWithProviders(
|
|
<Routes>
|
|
<Route path="/admin/clinic-services/:uuid" element={<ServiceDetailPage />} />
|
|
</Routes>,
|
|
{ route },
|
|
);
|
|
|
|
describe('ServiceDetailPage (جزئیات سرویس)', () => {
|
|
it('اطلاعات پایه سرویس را با breadcrumb نمایش میدهد', async () => {
|
|
render();
|
|
|
|
expect(await screen.findByRole('heading', { name: 'سرم ۵۰۰cc' })).toBeInTheDocument();
|
|
expect(screen.getByText('سرویسها')).toBeInTheDocument();
|
|
expect(screen.getAllByText('تزریقات').length).toBeGreaterThan(0);
|
|
expect(screen.getByText('مریم امینی')).toBeInTheDocument();
|
|
expect(screen.getByText('۳۰ دقیقه')).toBeInTheDocument();
|
|
expect(screen.getByText('تحت پوشش')).toBeInTheDocument();
|
|
});
|
|
|
|
it('تاریخ ایجاد و آخرین ویرایش را شمسی نشان میدهد', async () => {
|
|
render();
|
|
|
|
expect(await screen.findByText('تاریخ ایجاد')).toBeInTheDocument();
|
|
expect(screen.getByText('آخرین ویرایش')).toBeInTheDocument();
|
|
// سال شمسی معادل ۱۷۰۰۰۰۰۰۰۰ ⇒ ۱۴۰۲
|
|
expect(screen.getByText(/۱۴۰۲/)).toBeInTheDocument();
|
|
});
|
|
|
|
/** تعرفهٔ سالانه حذف شد: قیمت فقط روی خودِ سرویس تعریف میشود. */
|
|
it('دیگر تب تعرفهها ندارد', async () => {
|
|
render();
|
|
await screen.findByRole('heading', { name: 'سرم ۵۰۰cc' });
|
|
|
|
expect(screen.queryByText('تعرفهها')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('تب بیمهها قراردادها و درصد پوشش را میآورد', async () => {
|
|
render();
|
|
fireEvent.click(await screen.findByText('بیمهها'));
|
|
|
|
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
|
|
expect(screen.getByText(/پوشش ۷۰٪/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('سرویس ناموجود → پیام خطا و دکمه بازگشت', async () => {
|
|
mockApi(null, true);
|
|
render();
|
|
|
|
expect(await screen.findByText('سرویس یافت نشد')).toBeInTheDocument();
|
|
expect(screen.getByText('بازگشت به سرویسها')).toBeInTheDocument();
|
|
});
|
|
|
|
it('دکمه ویرایش، فرم مشترک سرویس را باز میکند', async () => {
|
|
render();
|
|
fireEvent.click(await screen.findByText('ویرایش'));
|
|
|
|
expect(await screen.findByText('ویرایش سرویس')).toBeInTheDocument();
|
|
// تنظیمات بیمه نباید در فرم باشد
|
|
expect(screen.queryByText('این خدمت شامل بیمه میشود')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('تب کالاهای مرتبط، اقلام پکیج متصل را نشان میدهد', async () => {
|
|
mockApi({ ...ITEM, inventory_package_uuid: 'pkg1', inventory_package_title: 'پکیج سرم' });
|
|
render();
|
|
fireEvent.click(await screen.findByText('کالاهای مرتبط'));
|
|
|
|
expect(await screen.findByText('پکیج سرم')).toBeInTheDocument();
|
|
expect(screen.getByText('سرم رینگر')).toBeInTheDocument();
|
|
expect(screen.getByText('۲ عدد')).toBeInTheDocument();
|
|
});
|
|
|
|
it('سرویس بدون هیچ کالایی، حالت خالی با لینک انبار میدهد', async () => {
|
|
render();
|
|
fireEvent.click(await screen.findByText('کالاهای مرتبط'));
|
|
|
|
expect(await screen.findByText('کالایی به این سرویس متصل نیست')).toBeInTheDocument();
|
|
expect(screen.getByText('مدیریت انبار')).toBeInTheDocument();
|
|
});
|
|
|
|
it('پکیج و کالای تکی همزمان در تب کالاها نمایش داده میشوند', async () => {
|
|
mockApi({
|
|
...ITEM,
|
|
inventory_package_uuid: 'pkg1',
|
|
inventory_package_title: 'پکیج سرم',
|
|
consumables: [{ item_uuid: 'inv1', name: 'گاز استریل', unit: 'عدد', price: 50_000, stock: 100, amount: 3 }],
|
|
});
|
|
render();
|
|
fireEvent.click(await screen.findByText('کالاهای مرتبط'));
|
|
|
|
expect(await screen.findByText('پکیج')).toBeInTheDocument();
|
|
expect(screen.getByText('پکیج سرم')).toBeInTheDocument();
|
|
expect(screen.getByText('کالاهای تکی')).toBeInTheDocument();
|
|
expect(screen.getByText('گاز استریل')).toBeInTheDocument();
|
|
expect(screen.getByText('۳ عدد')).toBeInTheDocument();
|
|
});
|
|
|
|
it('کالای تکی با موجودی ناکافی نشاندار میشود', async () => {
|
|
mockApi({
|
|
...ITEM,
|
|
consumables: [{ item_uuid: 'inv1', name: 'گاز استریل', unit: 'عدد', price: 50_000, stock: 1, amount: 5 }],
|
|
});
|
|
render();
|
|
fireEvent.click(await screen.findByText('کالاهای مرتبط'));
|
|
|
|
expect(await screen.findByText('موجودی ناکافی')).toBeInTheDocument();
|
|
});
|
|
|
|
it('تب لاگ تغییرات، مقدار قبل و بعد را خوانا نشان میدهد', async () => {
|
|
render();
|
|
fireEvent.click(await screen.findByText('لاگ تغییرات'));
|
|
|
|
expect(await screen.findByText('قیمت پایه')).toBeInTheDocument();
|
|
expect(screen.getByText('۷۰۰٬۰۰۰ تومان')).toBeInTheDocument();
|
|
expect(screen.getByText('۸۵۰٬۰۰۰ تومان')).toBeInTheDocument();
|
|
expect(screen.getByText('ایجاد')).toBeInTheDocument();
|
|
expect(screen.getAllByText(/توسط دکتر تست/).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('سرویس غیرفعال نشان «غیرفعال» و دکمه فعالکردن دارد', async () => {
|
|
mockApi({ ...ITEM, active: false });
|
|
render();
|
|
|
|
expect(await screen.findByText('غیرفعال')).toBeInTheDocument();
|
|
expect(screen.getByText('فعالکردن')).toBeInTheDocument();
|
|
});
|
|
|
|
/**
|
|
* گروههای انتخاب و بخشهای نوبت از صفحهٔ سرویس برداشته شدند: هر چیزی که به یک سرویس
|
|
* مربوط است در خودِ سرویس تعریف میشود. مدل و موتور سر جایشاناند — تستهای
|
|
* `tests/Appointment` همان رفتار قبلی را قفل میکنند.
|
|
*/
|
|
it('دیگر تب گروهها و بخشهای نوبت ندارد', async () => {
|
|
render();
|
|
await screen.findByRole('heading', { name: 'سرم ۵۰۰cc' });
|
|
|
|
expect(screen.queryByText('گروهها و آیتمها')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('بخشهای نوبت')).not.toBeInTheDocument();
|
|
});
|
|
|
|
/** لینک قدیمی به تبِ حذفشده نباید صفحهٔ خالی بدهد. */
|
|
it('تبِ ناشناخته در URL به اطلاعات برمیگردد', async () => {
|
|
render('/admin/clinic-services/it1?tab=segments');
|
|
|
|
expect(await screen.findByText('تاریخ ایجاد')).toBeInTheDocument();
|
|
});
|
|
|
|
/** تب در URL مینشیند تا «بازگشت» و رفرش همان نما را بدهند. */
|
|
it('تب انتخابشده از URL خوانده میشود', async () => {
|
|
render('/admin/clinic-services/it1?tab=insurance');
|
|
|
|
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
|
|
});
|
|
});
|