- 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.
191 lines
8.9 KiB
TypeScript
191 lines
8.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { Routes, Route } from 'react-router';
|
|
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 {},
|
|
}));
|
|
|
|
import { api } from '../lib/api';
|
|
import AppointmentEditPage from './AppointmentEditPage';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const post = api.post as ReturnType<typeof vi.fn>;
|
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
|
|
|
const slotStart = Math.floor(new Date('2026-08-01T15:00').getTime() / 1000);
|
|
const slotEnd = Math.floor(new Date('2026-08-01T15:30').getTime() / 1000);
|
|
const newStart = Math.floor(new Date('2026-08-01T16:00').getTime() / 1000);
|
|
|
|
/**
|
|
* `bookingMode` روی محلِ خودِ نوبت است، پس پاسخ `appointment-booking-services` را با
|
|
* همان `doctor_uuid` و بدون `clinic_uuid` (مطب شخصی) میدهیم.
|
|
*/
|
|
function mockApi(opts: { mode: 'slot' | 'service'; isReserve?: boolean; serviceItems?: any[] }) {
|
|
get.mockImplementation((url: string) => {
|
|
if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: {
|
|
uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4,
|
|
doctor: { uuid: 'doc1', name: 'دکتر تست' },
|
|
clinic_uuid: null,
|
|
is_reserve: opts.isReserve ?? false,
|
|
service_total_minutes: opts.mode === 'service' ? 30 : null,
|
|
service_buffer_minutes: opts.mode === 'service' ? 10 : null,
|
|
service_section: { uuid: 'sec1', name: 'زیبایی' },
|
|
service_item: { uuid: 'it1', name: 'لیزر' },
|
|
service_items: opts.serviceItems ?? [{ uuid: 'it1', name: 'لیزر' }],
|
|
staff: { uuid: 'st1', full_name: 'سحر ایمانی' },
|
|
} } });
|
|
if (url.startsWith('/api/v1/appointment-booking-services/')) {
|
|
return Promise.resolve({ success: true, data: {
|
|
booking_mode: opts.mode,
|
|
buffer_minutes: 10,
|
|
services: [
|
|
{ uuid: 'it1', name: 'لیزر', duration_minutes: 30, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
|
{ uuid: 'it2', name: 'تزریق', duration_minutes: 15, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
|
],
|
|
} });
|
|
}
|
|
if (url.startsWith('/api/v1/appointment-service-slots')) {
|
|
return Promise.resolve({ success: true, data: {
|
|
total_duration_minutes: 30,
|
|
buffer_minutes: 10,
|
|
start_times: [{ start: newStart, end: newStart + 1800, start_time: '16:00' }],
|
|
} });
|
|
}
|
|
if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] });
|
|
if (url.startsWith('/api/v1/service-items/')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر' }] });
|
|
if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] });
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
}
|
|
|
|
function renderEdit() {
|
|
return renderWithProviders(
|
|
<Routes><Route path="/admin/appointments/:uuid/edit" element={<AppointmentEditPage />} /></Routes>,
|
|
{ route: '/admin/appointments/ap1/edit' },
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
get.mockReset(); post.mockReset(); patch.mockReset();
|
|
post.mockResolvedValue({ success: true, data: {} });
|
|
patch.mockResolvedValue({ success: true, data: {} });
|
|
});
|
|
|
|
describe('AppointmentEditPage — حالت نوبتدهی سرویسی', () => {
|
|
it('ورودی دستی ساعت را پنهان میکند و انتخابگر سرویس را نشان میدهد', async () => {
|
|
mockApi({ mode: 'service' });
|
|
renderEdit();
|
|
|
|
await screen.findByText('مشخصات سرویس:');
|
|
// انتخابگر سرویس هم برچسب «بخش» دارد؛ ظاهر شدن دو تا نشانهٔ mount شدنش است.
|
|
await waitFor(() => expect(screen.getAllByText('بخش')).toHaveLength(2));
|
|
|
|
expect(screen.queryByLabelText('ساعت شروع')).not.toBeInTheDocument();
|
|
expect(screen.queryByLabelText('ساعت پایان')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('دکمهٔ ثبت تا انتخاب زمان غیرفعال است و راهنمای فارسی دارد', async () => {
|
|
mockApi({ mode: 'service' });
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
|
|
expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).toBeDisabled();
|
|
expect(screen.getByText('برای ثبت، سرویس و سپس یکی از زمانهای خالی را انتخاب کنید.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('exclude_appointment_uuid را به درخواست اسلاتها میدهد', async () => {
|
|
mockApi({ mode: 'service' });
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
|
|
await waitFor(() => {
|
|
const calls = get.mock.calls.map((c) => String(c[0]));
|
|
expect(calls.some((u) => u.includes('appointment-service-slots') && u.includes('exclude_appointment_uuid=ap1'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('ثبت، service-reschedule را بدون هیچ مدتی صدا میزند', async () => {
|
|
mockApi({ mode: 'service' });
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '16:00' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
|
|
|
await waitFor(() => expect(post).toHaveBeenCalledWith(
|
|
'/api/v1/appointment/ap1/service-reschedule',
|
|
expect.objectContaining({ start: newStart, service_item_uuids: ['it1'] }),
|
|
));
|
|
|
|
const body = post.mock.calls[0][1] as Record<string, unknown>;
|
|
expect(body).not.toHaveProperty('slot_end');
|
|
expect(body).not.toHaveProperty('total_duration_minutes');
|
|
});
|
|
|
|
it('PATCH بعدی زمان و نسخه را نمیفرستد (نسخه یک قدم جلو رفته)', async () => {
|
|
mockApi({ mode: 'service' });
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '16:00' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
|
|
|
await waitFor(() => expect(patch).toHaveBeenCalled());
|
|
const body = patch.mock.calls[0][1] as Record<string, unknown>;
|
|
expect(body).not.toHaveProperty('slot_start');
|
|
expect(body).not.toHaveProperty('slot_end');
|
|
expect(body).not.toHaveProperty('version');
|
|
expect(body).toHaveProperty('deposit_required');
|
|
});
|
|
|
|
it('سرویسِ غیرفعالِ نوبت را با هشدار فارسی گزارش میکند', async () => {
|
|
mockApi({ mode: 'service', serviceItems: [{ uuid: 'it1', name: 'لیزر' }, { uuid: 'gone', name: 'حذفشده' }] });
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
|
|
expect(await screen.findByText(/۱ سرویس این نوبت دیگر برای نوبتدهی فعال نیست|1 سرویس این نوبت دیگر برای نوبتدهی فعال نیست/))
|
|
.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('AppointmentEditPage — خط سرخ: حالت اسلاتی و رزرو', () => {
|
|
it('حالت اسلاتی سه فیلد ساعت را حفظ میکند و انتخابگر ندارد', async () => {
|
|
mockApi({ mode: 'slot' });
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
|
|
expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00');
|
|
expect(screen.getByLabelText('ساعت پایان')).toBeInTheDocument();
|
|
expect(screen.queryByText('زمانهای خالی پیشنهادی')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('حالت اسلاتی همان PATCH قبلی با version را میفرستد', async () => {
|
|
mockApi({ mode: 'slot' });
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
// پیش از کلیک باید هیدریت شده باشد، وگرنه toEpoch('','') مقدار NaN میسازد.
|
|
await waitFor(() => expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00'));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
|
|
|
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
|
|
slot_start: slotStart,
|
|
slot_end: slotEnd,
|
|
version: 4,
|
|
})));
|
|
expect(post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('نوبت رزرو در محل سرویسی هم انتخابگر زمان نمیگیرد', async () => {
|
|
mockApi({ mode: 'service', isReserve: true });
|
|
renderEdit();
|
|
await screen.findByText('مشخصات سرویس:');
|
|
|
|
expect(screen.getByLabelText('ساعت شروع')).toBeInTheDocument();
|
|
expect(screen.queryByText('زمانهای خالی پیشنهادی')).not.toBeInTheDocument();
|
|
});
|
|
});
|