Files
clinicpro/assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- 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.
2026-08-07 21:13:38 +03:30

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();
});
});