- 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.
294 lines
14 KiB
TypeScript
294 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
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() } }));
|
|
vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) }));
|
|
|
|
import { Routes, Route } from 'react-router';
|
|
import { api } from '../lib/api';
|
|
import ResourceDetailPage from './ResourceDetailPage';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
|
|
|
const resource = {
|
|
uuid: 'r1',
|
|
name: 'لیزر دایود',
|
|
address_uuid: 'a1',
|
|
address_name: 'شعبهٔ مرکزی',
|
|
type_uuid: 't1',
|
|
type_code: 'device',
|
|
type_name: 'دستگاه لیزر',
|
|
capacity: 1,
|
|
setup_minutes: 5,
|
|
cleanup_minutes: 10,
|
|
attributes: {},
|
|
subject_kind: null,
|
|
subject_uuid: null,
|
|
skills: [{ skill_uuid: 's1', skill_name: 'کار با لیزر', level: 4 }],
|
|
categories: [{ uuid: 'c-hand', name: 'دست' }],
|
|
active: true,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
upcoming_appointments: 0,
|
|
};
|
|
|
|
const emptyDays = (): Record<string, unknown[]> =>
|
|
Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]]));
|
|
|
|
function mockApi(days: Record<string, unknown[]> = emptyDays()) {
|
|
get.mockImplementation((path: string) => {
|
|
if (path === '/api/v1/resource/r1') return Promise.resolve({ success: true, data: resource });
|
|
if (path.endsWith('/calendar')) {
|
|
return Promise.resolve({
|
|
success: true,
|
|
data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', defined: true, days },
|
|
});
|
|
}
|
|
if (path.includes('/service-categories/tree')) {
|
|
return Promise.resolve({
|
|
success: true,
|
|
data: [{ uuid: 'c-hand', name: 'دست', sort_order: 0, active: true, children: [] },
|
|
{ uuid: 'c-foot', name: 'پا', sort_order: 1, active: true, children: [] }],
|
|
});
|
|
}
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
}
|
|
|
|
function renderPage(route = '/admin/resources/r1') {
|
|
return renderWithProviders(
|
|
<Routes>
|
|
<Route path="/admin/resources/:resourceUuid" element={<ResourceDetailPage />} />
|
|
</Routes>,
|
|
{ route },
|
|
);
|
|
}
|
|
|
|
describe('ResourceDetailPage', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it('اطلاعات منبع را در تب پیشفرض نشان میدهد', async () => {
|
|
mockApi();
|
|
renderPage();
|
|
|
|
await waitFor(() => expect(screen.getByText('ظرفیت همزمان')).toBeInTheDocument());
|
|
expect(screen.getByText('ظرفیت همزمان')).toBeInTheDocument();
|
|
expect(screen.getByText('کار با لیزر · 4')).toBeInTheDocument();
|
|
});
|
|
|
|
it('تب از URL خوانده میشود تا بازگشت و رفرش همان نما را بدهد', async () => {
|
|
mockApi();
|
|
renderPage('/admin/resources/r1?tab=hours');
|
|
|
|
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
|
expect(screen.getByText('جمعه')).toBeInTheDocument();
|
|
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
|
|
});
|
|
|
|
it('شیفت ذخیرهشده را بهصورت ساعت نشان میدهد', async () => {
|
|
const days = emptyDays();
|
|
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
|
|
mockApi(days);
|
|
renderPage('/admin/resources/r1?tab=hours');
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
|
expect(screen.getByDisplayValue('17:00')).toBeInTheDocument();
|
|
});
|
|
|
|
/** نوار ذخیره فقط وقتی ظاهر میشود که چیزی تغییر کرده باشد — خودش نشانهٔ dirty است. */
|
|
it('نوار ذخیره تا وقتی تغییری نباشد نمیآید', async () => {
|
|
const user = userEvent.setup();
|
|
mockApi();
|
|
renderPage('/admin/resources/r1?tab=hours');
|
|
|
|
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
|
expect(screen.queryByRole('button', { name: 'ذخیرهٔ شیفتها' })).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[0]);
|
|
|
|
expect(await screen.findByText('تغییرات شیفت ذخیره نشده است')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'ذخیرهٔ شیفتها' })).toBeInTheDocument();
|
|
});
|
|
|
|
/** «بازگرداندن» باید به نسخهٔ سرور برگردد، نه صرفاً آخرین ویرایش را لغو کند. */
|
|
it('بازگرداندن، draft را به نسخهٔ سرور برمیگرداند', async () => {
|
|
const user = userEvent.setup();
|
|
mockApi();
|
|
renderPage('/admin/resources/r1?tab=hours');
|
|
|
|
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
|
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[0]);
|
|
await user.click(await screen.findByRole('button', { name: 'بازگرداندن' }));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByText('تغییرات شیفت ذخیره نشده است')).not.toBeInTheDocument());
|
|
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
|
|
});
|
|
|
|
/**
|
|
* دو شیفتِ متداخل در یک روز نباید ذخیره شود. سرور هم همین را رد میکند، ولی خطا
|
|
* باید حین ویرایش دیده شود نه بعد از رفتوبرگشت شبکه.
|
|
*/
|
|
it('شیفتهای متداخل را رد میکند و ذخیره را میبندد', async () => {
|
|
const days = emptyDays();
|
|
days['0'] = [
|
|
{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true },
|
|
{ sequence: 1, start_minute: 1020, end_minute: 1140, start_time: '17:00', end_time: '19:00', active: true },
|
|
];
|
|
const user = userEvent.setup();
|
|
mockApi(days);
|
|
renderPage('/admin/resources/r1?tab=hours');
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('17:00')).toBeInTheDocument());
|
|
expect(screen.queryByText(/تداخل دارند/)).not.toBeInTheDocument();
|
|
|
|
// ۱۷:۰۰–۱۹:۰۰ به ۱۲:۰۰ کشیده میشود و با ۰۹:۰۰–۱۳:۰۰ تداخل پیدا میکند.
|
|
await user.clear(screen.getByLabelText('ساعت شروع شیفت ۲ روز شنبه'));
|
|
await user.type(screen.getByLabelText('ساعت شروع شیفت ۲ روز شنبه'), '12:00');
|
|
|
|
// پیام دو جا میآید و باید بیاید: کنار خودِ روز، و در نوار ذخیره که دلیل قفل را میگوید.
|
|
expect(await screen.findAllByText(/شیفتهای روز شنبه با هم تداخل دارند/)).toHaveLength(2);
|
|
expect(screen.getByRole('button', { name: 'ذخیرهٔ شیفتها' })).toBeDisabled();
|
|
});
|
|
|
|
/** بازهٔ بدون تداخل نباید قربانی اعتبارسنجی شود — مرز چسبیده مجاز است. */
|
|
it('شیفتهای چسبیده اما بدون همپوشانی را میپذیرد', async () => {
|
|
const days = emptyDays();
|
|
days['0'] = [
|
|
{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true },
|
|
{ sequence: 1, start_minute: 780, end_minute: 1020, start_time: '13:00', end_time: '17:00', active: true },
|
|
];
|
|
const user = userEvent.setup();
|
|
mockApi(days);
|
|
renderPage('/admin/resources/r1?tab=hours');
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
|
expect(screen.queryByText(/تداخل دارند/)).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[1]);
|
|
|
|
expect(await screen.findByRole('button', { name: 'ذخیرهٔ شیفتها' })).toBeEnabled();
|
|
});
|
|
|
|
/** آیکون کپی گمراهکننده بود و حذف شد. */
|
|
it('دکمهٔ کپی شیفت ندارد', async () => {
|
|
const days = emptyDays();
|
|
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
|
|
mockApi(days);
|
|
renderPage('/admin/resources/r1?tab=hours');
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
|
expect(screen.queryByRole('button', { name: /کپی/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
/**
|
|
* دستهبندی از این صفحه برداشته شد: چیزی که کاربر مینوشت هیچجا خوانده نمیشد و
|
|
* «کدام منبع این سرویس را میدهد» را تب سرویسها صریحتر جواب میدهد.
|
|
*/
|
|
it('تب دستهبندی وجود ندارد و لینک قدیمی به تب اطلاعات میافتد', async () => {
|
|
mockApi();
|
|
renderPage('/admin/resources/r1?tab=categories');
|
|
|
|
// نبودِ تب وقتی معنا دارد که صفحه واقعاً رندر شده باشد.
|
|
await waitFor(() => expect(screen.getByText('ظرفیت همزمان')).toBeInTheDocument());
|
|
expect(screen.queryByRole('button', { name: 'دستهبندیها' })).not.toBeInTheDocument();
|
|
expect(screen.queryByText('دست')).not.toBeInTheDocument();
|
|
});
|
|
|
|
/** تب استثناها فقط دو ورودی دارد: تعطیلات رسمی، و مرخصی و سرویس. */
|
|
it('تب تعطیلات پیشنمایش دو هفته را ندارد', async () => {
|
|
mockApi();
|
|
renderPage('/admin/resources/r1?tab=exceptions');
|
|
|
|
await waitFor(() => expect(screen.getByText('مرخصی و سرویس')).toBeInTheDocument());
|
|
expect(screen.queryByText('پیشنمایش دو هفته')).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/نوبتهای ثبتشده هنوز از آن کسر نشدهاند/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
/**
|
|
* مسدودسازی موردی از مودالِ جدول به تبِ خودِ منبع آمد و «غیرفعالسازی موقت» نام گرفت.
|
|
* متن تب باید تفاوتش با استثنای تقویم را بگوید، وگرنه اپراتور تعطیلیِ یک بعدازظهر را
|
|
* برای همیشه ثبت میکند.
|
|
*/
|
|
it('تب غیرفعالسازی موقت، بازهها را جدا از الگوی کاری معرفی میکند', async () => {
|
|
mockApi();
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
|
|
await waitFor(() => expect(screen.getByText('ظرفیت همزمان')).toBeInTheDocument());
|
|
await user.click(screen.getByRole('button', { name: 'غیرفعالسازی موقت' }));
|
|
|
|
expect(await screen.findByText(/یک بازهٔ مشخص را میبندد/)).toBeInTheDocument();
|
|
// «الگوی کاری» فقط در همین راهنما میآید — نامِ تب در چند جا تکرار میشود.
|
|
expect(screen.getByText(/الگوی کاری/)).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: 'غیرفعالسازی موقت' })).toBeInTheDocument();
|
|
});
|
|
|
|
/**
|
|
* حذفِ منبع عمداً در رابط کاربری نیست: منبع به نوبتهای ثبتشده گره خورده و راهِ
|
|
* درستِ «دیگر لازمش ندارم» غیرفعال کردن است.
|
|
*/
|
|
it('تب اطلاعات کلید فعال/غیرفعال دارد و حذف منبع را نه', async () => {
|
|
mockApi();
|
|
renderPage();
|
|
|
|
await waitFor(() => expect(screen.getByText('ظرفیت همزمان')).toBeInTheDocument());
|
|
|
|
// منبعِ نمونه فعال است، پس اقدام «غیرفعال کردن» پیشنهاد میشود.
|
|
expect(screen.getByRole('button', { name: 'غیرفعال کردن' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'حذف' })).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/حذف منبع/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
/** غیرفعال کردن منبع را از نوبتدهی بیرون میبرد، پس بیتأیید انجام نمیشود. */
|
|
it('غیرفعال کردن اول تأیید میگیرد و بدون تأیید درخواستی نمیفرستد', async () => {
|
|
mockApi();
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
|
|
await waitFor(() => expect(screen.getByText('ظرفیت همزمان')).toBeInTheDocument());
|
|
await user.click(screen.getByRole('button', { name: 'غیرفعال کردن' }));
|
|
|
|
expect(await screen.findByText(/در جستجوی وقت و رزرو نوبت ظاهر نمیشود/)).toBeInTheDocument();
|
|
expect(patch).not.toHaveBeenCalled();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'غیرفعال کن' }));
|
|
|
|
await waitFor(() =>
|
|
expect(patch).toHaveBeenCalledWith('/api/v1/resource/r1', { active: false }));
|
|
});
|
|
|
|
/**
|
|
* رگرسیون: جهش فقط کلیدِ فهرست (`resources`) را باطل میکرد، نه
|
|
* `resource-detail` را — پس وضعیت روی همین صفحه تا رفرشِ دستی کهنه میماند.
|
|
*/
|
|
it('وضعیت بعد از غیرفعال کردن بدون رفرش بهروز میشود', async () => {
|
|
mockApi();
|
|
const user = userEvent.setup();
|
|
patch.mockResolvedValue({ success: true, data: { ...resource, active: false } });
|
|
renderPage();
|
|
|
|
await waitFor(() => expect(screen.getByText('فعال')).toBeInTheDocument());
|
|
|
|
// پاسخِ بعدیِ سرور برای همین منبع، غیرفعال است.
|
|
get.mockImplementation((path: string) =>
|
|
path === '/api/v1/resource/r1'
|
|
? Promise.resolve({ success: true, data: { ...resource, active: false } })
|
|
: Promise.resolve({ success: true, data: [] }));
|
|
|
|
await user.click(screen.getByRole('button', { name: 'غیرفعال کردن' }));
|
|
await user.click(await screen.findByRole('button', { name: 'غیرفعال کن' }));
|
|
|
|
// بدون باطلکردنِ `resource-detail`، این همیشه «فعال» میماند.
|
|
expect(await screen.findByText('غیرفعال')).toBeInTheDocument();
|
|
expect(await screen.findByRole('button', { name: 'فعال کردن' })).toBeInTheDocument();
|
|
});
|
|
});
|