- 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.
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { screen, fireEvent, 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 {},
|
||
}));
|
||
|
||
const navigate = vi.fn();
|
||
vi.mock('react-router', async () => ({
|
||
...(await vi.importActual<typeof import('react-router')>('react-router')),
|
||
useNavigate: () => navigate,
|
||
}));
|
||
|
||
import { api } from '../lib/api';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import DashboardPage from './DashboardPage';
|
||
import { JALALI_MONTHS } from '../components/dashboard/TauriDashboardView';
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||
const jalaali = require('jalaali-js') as { toJalaali: (d: Date) => { jy: number; jm: number } };
|
||
|
||
const get = api.get as ReturnType<typeof vi.fn>;
|
||
|
||
const clinicPayload = {
|
||
success: true,
|
||
data: {
|
||
clinic: { uuid: 'clinic-1', name: 'کلینیک نمونه', is_active: true, logo: null },
|
||
stats: {
|
||
total_doctors: 2,
|
||
today_appointments: 15,
|
||
this_month_appointments: 40,
|
||
pending_invitations: 0,
|
||
total_patients: 151,
|
||
revenue_period_rials: 5_600_002_000,
|
||
today_payments_rials: 5_225_000,
|
||
week_payments_rials: 12_000_000,
|
||
},
|
||
charts: {
|
||
revenue_by_day: [
|
||
{ label: '۷ خرداد', amount_rials: 60_000 },
|
||
{ label: '۸ خرداد', amount_rials: 160_000 },
|
||
{ label: '۹ خرداد', amount_rials: 90_000 },
|
||
],
|
||
revenue_by_month: [
|
||
{ label: 'فروردین', amount_rials: 60_000 },
|
||
{ label: 'اردیبهشت', amount_rials: 160_000 },
|
||
{ label: 'خرداد', amount_rials: 90_000 },
|
||
],
|
||
appointments_by_day: [
|
||
{ label: '۱', count: 15 },
|
||
{ label: '۲', count: 40 },
|
||
{ label: '۳', count: 48 },
|
||
],
|
||
},
|
||
charts_period: { patients_year: 1405, patients_month: 4, revenue_year: 1405 },
|
||
today_appointments: [
|
||
{
|
||
uuid: 'appt-1',
|
||
patient_name: 'دنیا خلیلی',
|
||
patient_mobile: '09136549874',
|
||
doctor_name: 'حمیدی',
|
||
service_name: 'ویزیت عمومی',
|
||
slot_start: 1_718_000_000,
|
||
slot_end: 1_718_001_800,
|
||
status: 'completed',
|
||
},
|
||
],
|
||
doctors: [],
|
||
period: { from: 0, to: 0 },
|
||
},
|
||
};
|
||
|
||
describe('DashboardPage (ported clinic dashboard)', () => {
|
||
beforeEach(() => {
|
||
get.mockReset();
|
||
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic-1', context: null } as never);
|
||
get.mockImplementation((url: string) => {
|
||
if (url.startsWith('/api/v1/dashboard/clinic')) return Promise.resolve(clinicPayload);
|
||
return Promise.resolve({ success: true, data: [] });
|
||
});
|
||
});
|
||
|
||
it('renders the ported stat cards, charts and new-appointments list', async () => {
|
||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||
|
||
// stat card labels (ported 1:1)
|
||
expect(await screen.findByText('تعداد کل مراجعین')).toBeInTheDocument();
|
||
expect(screen.getByText('کل پرداختیها')).toBeInTheDocument();
|
||
expect(screen.getByText('پرداختیهای امروز')).toBeInTheDocument();
|
||
expect(screen.getByText('تعداد نوبتهای امروز')).toBeInTheDocument();
|
||
|
||
// chart titles
|
||
expect(screen.getByText('نمودار تعداد بیماران')).toBeInTheDocument();
|
||
expect(screen.getByText('میزان درآمد')).toBeInTheDocument();
|
||
|
||
// new-appointments list + row data (mobile/service columns come from the extended API)
|
||
expect(screen.getByText('لیست نوبتهای جدید')).toBeInTheDocument();
|
||
expect(screen.getByText('دنیا خلیلی')).toBeInTheDocument();
|
||
expect(screen.getByText('09136549874')).toBeInTheDocument();
|
||
expect(screen.getByText('ویزیت عمومی')).toBeInTheDocument();
|
||
|
||
// ستون «وضعیت» هست، ولی در داشبورد فقط خواندنی (بدون queryKey → StatusPill نه dropdown)
|
||
expect(screen.getByText('وضعیت')).toBeInTheDocument();
|
||
expect(screen.getByText('ویزیت شده').closest('button')).toBeNull();
|
||
|
||
// کارت «پزشکان کلینیک» از داشبورد حذف شده
|
||
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('«مشاهده» نوبتِ قطعیشده، پروندهٔ بیمار را باز میکند', async () => {
|
||
// fixture وضعیت `completed` دارد — یعنی از قطعیشدن رد شده و پرونده دارد.
|
||
get.mockImplementation((url: string) => {
|
||
if (url.startsWith('/api/v1/patients?search='))
|
||
return Promise.resolve({ success: true, data: [{ uuid: 'rec-42' }] });
|
||
return Promise.resolve(clinicPayload);
|
||
});
|
||
|
||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||
await screen.findByText('دنیا خلیلی');
|
||
|
||
fireEvent.click(screen.getByText('مشاهده'));
|
||
|
||
await waitFor(() => expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/patients?search=09136549874')));
|
||
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/admin/patients/rec-42'));
|
||
});
|
||
|
||
it('«مشاهده» نوبتِ ثبتشده (قطعینشده) به لیست نوبتهای همان روز میرود', async () => {
|
||
get.mockResolvedValue({
|
||
...clinicPayload,
|
||
data: {
|
||
...clinicPayload.data,
|
||
today_appointments: [{ ...clinicPayload.data.today_appointments[0], status: 'pending' }],
|
||
},
|
||
});
|
||
|
||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||
await screen.findByText('دنیا خلیلی');
|
||
|
||
const d = new Date(1_718_000_000 * 1000);
|
||
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
|
||
expect(screen.getByText('مشاهده').closest('a')).toHaveAttribute('href', `/admin/appointments?date=${day}`);
|
||
});
|
||
|
||
it('سلولهای جدول با هدرهایشان همتراز هستند (همه text-start جز ستون عملیات)', async () => {
|
||
const { container } = renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||
await screen.findByText('دنیا خلیلی');
|
||
|
||
const heads = Array.from(container.querySelectorAll('thead th'));
|
||
const cells = Array.from(container.querySelectorAll('tbody td'));
|
||
expect(heads).toHaveLength(cells.length);
|
||
|
||
heads.slice(0, -1).forEach(th => expect(th.className).toContain('text-start'));
|
||
cells.slice(0, -1).forEach(td => expect(td.className).toContain('text-start'));
|
||
|
||
// ستون آخر «عملیات» در هر دو سطر وسطچین است
|
||
expect(heads[heads.length - 1].className).toContain('text-center');
|
||
expect(cells[cells.length - 1].className).toContain('text-center');
|
||
|
||
// dir=ltr روی سلول نمینشیند (جهت را برمیگرداند و همترازی را میشکند)
|
||
cells.forEach(td => expect(td.getAttribute('dir')).toBeNull());
|
||
});
|
||
|
||
it('calls the real clinic dashboard endpoint', async () => {
|
||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||
await screen.findByText('تعداد کل مراجعین');
|
||
expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/dashboard/clinic'));
|
||
});
|
||
|
||
it('نمودارها را روی ماه و سال شمسی جاری درخواست میکند', async () => {
|
||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||
await screen.findByText('تعداد کل مراجعین');
|
||
|
||
const url = get.mock.calls.map(c => String(c[0])).find(u => u.startsWith('/api/v1/dashboard/clinic'))!;
|
||
const params = new URLSearchParams(url.split('?')[1]);
|
||
const now = jalaali.toJalaali(new Date());
|
||
|
||
expect(params.get('patients_year')).toBe(String(now.jy));
|
||
expect(params.get('patients_month')).toBe(String(now.jm));
|
||
expect(params.get('revenue_year')).toBe(String(now.jy));
|
||
|
||
// سلکتورها همان دوره را نشان میدهند
|
||
expect(screen.getByText(JALALI_MONTHS[now.jm - 1])).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText(`سال ${new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(now.jy)}`)
|
||
).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe('داشبورد پرسنل', () => {
|
||
const staffPayload = {
|
||
success: true,
|
||
data: {
|
||
scope: 'clinic',
|
||
staff: { uuid: 'st-1', full_name: 'پرسنل۱', job_title: 'پرسنل لیزر' },
|
||
owner: { name: 'مدیسا' },
|
||
stats: { today_sessions: 2, today_appointments: 0, services: 1 },
|
||
services: [{ uuid: 'svc-1', name: 'لیزر توتال', section_name: 'لیزر', price_rials: 10_000_000, duration_minutes: 40 }],
|
||
today_sessions: [
|
||
{ uuid: 'ses-1', session_number: 1, total_sessions: 3, status: 'in_progress', service_name: 'لیزر توتال', patient_name: 'محمد رسولی', slot_start: 1_786_084_200 },
|
||
{ uuid: 'ses-2', session_number: 1, total_sessions: 3, status: 'done', service_name: 'لیزر توتال', patient_name: 'محمد رستمی', slot_start: 1_786_080_600 },
|
||
],
|
||
today_appointments: [],
|
||
},
|
||
};
|
||
|
||
beforeEach(() => {
|
||
get.mockReset();
|
||
useAuthStore.setState({ primaryRole: 'staff' } as never);
|
||
get.mockResolvedValue(staffPayload);
|
||
});
|
||
|
||
/** کارِ اپراتور جلسهٔ درمان است؛ `appointments.staff_id` را هیچ مسیری پر نمیکند. */
|
||
it('کار امروز را از جلسات درمان میسازد، نه نوبتهای اختصاصیافته', async () => {
|
||
renderWithProviders(<DashboardPage />);
|
||
|
||
expect(await screen.findByText('محمد رسولی')).toBeInTheDocument();
|
||
expect(screen.getByText('محمد رستمی')).toBeInTheDocument();
|
||
});
|
||
|
||
it('کارت جلسات لینک است', async () => {
|
||
renderWithProviders(<DashboardPage />);
|
||
|
||
const sessions = (await screen.findByText('جلسات امروز من')).closest('a');
|
||
expect(sessions).toHaveAttribute('href', '/admin/my-sessions');
|
||
});
|
||
|
||
/** سرویسها کارِ اپراتور نیستند؛ داشبورد فقط کارِ امروز را نشان میدهد. */
|
||
it('هیچجای داشبورد سرویسها را نشان نمیدهد', async () => {
|
||
renderWithProviders(<DashboardPage />);
|
||
await screen.findByText('محمد رسولی');
|
||
|
||
expect(screen.queryByText('سرویسهای من')).not.toBeInTheDocument();
|
||
expect(screen.queryByText('سرویسهای تخصیصیافته')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('هر ردیف کار به همان جلسه میرود', async () => {
|
||
renderWithProviders(<DashboardPage />);
|
||
|
||
const row = (await screen.findByText('محمد رسولی')).closest('a');
|
||
expect(row).toHaveAttribute('href', '/admin/my-sessions/ses-1');
|
||
});
|
||
|
||
/** جدولِ همیشه-خالی زیر کارِ واقعی فقط صفحه را بلند میکرد. */
|
||
it('نوبتهای اختصاصیافته وقتی خالی است اصلاً نمیآید', async () => {
|
||
renderWithProviders(<DashboardPage />);
|
||
await screen.findByText('محمد رسولی');
|
||
|
||
expect(screen.queryByText('نوبتهای اختصاصیافته به من')).not.toBeInTheDocument();
|
||
});
|
||
});
|