Files
clinicpro/assets/admin/pages/ServiceDetailPage.test.tsx
T
hamedandClaude Opus 5 b8e8580867 refactor(services): drop the groups and segments tabs
Anything that belongs to a service is defined on the service itself, so the
two tabs that managed selection groups and appointment segments come off the
service page.

Only the UI goes. SegmentTemplate is what makes a service occupy a room and a
device at the same time — it is the input to AppointmentPlanBuilder and the
reason the resource timeline has anything to draw — and a service without a
template already books through singleSegment(). Removing the model would
change booking; removing the tabs does not, which tests/Appointment confirms
at 314 green.

The active tab moved into the query string on the way past. That is what
makes the old ?tab=segments link land on the info tab instead of rendering
nothing, and it lets back and refresh return to the same tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:56:49 +03:30

233 lines
11 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
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('/tariffs')) return Promise.resolve({ success: true, data: {
current_year: 1404, default_price_rials: 8_500_000,
data: [
{ uuid: 't1', year: 1404, price_rials: 8_500_000, is_active: true },
{ uuid: 't2', year: 1403, price_rials: 7_000_000, is_active: false },
],
} });
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();
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(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=tariffs');
expect(await screen.findByText('سال جاری')).toBeInTheDocument();
});
});