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 {}, })); import { api } from '../../lib/api'; import CreateStep, { patientShareOf } from './CreateStep'; const get = api.get as ReturnType; const post = api.post as ReturnType; function mockEndpoints(pricing: { free_visit_price_rials: number; require_visit_price: boolean }) { get.mockImplementation((url: string) => { if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: pricing }); if (url === '/api/v1/inventory-items') return Promise.resolve({ success: true, data: { items: [] } }); if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [] } }); return Promise.resolve({ success: true, data: [] }); }); } beforeEach(() => { get.mockReset(); post.mockReset(); post.mockResolvedValue({ success: true, data: { uuid: 's-1' } }); }); const renderStep = (onCreated = vi.fn()) => { renderWithProviders( {}} />, ); return onCreated; }; describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ require_visit_price', () => { it('فلگ فعال + قیمت صفر → خطای inline، ستاره روی label و عدم ارسال', async () => { mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true }); renderStep(); await waitFor(() => expect(screen.getByText(/قیمت ویزیت \(تومان\)/)).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('*')).toBeInTheDocument()); fireEvent.click(screen.getByText('ایجاد سرویس')); expect(await screen.findByText('هزینه ویزیت الزامی است')).toBeInTheDocument(); expect(post).not.toHaveBeenCalled(); }); it('فلگ فعال + مقدار معتبر → POST با visit_price_rials ریالی', async () => { mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true }); renderStep(); await waitFor(() => expect(screen.getByText('*')).toBeInTheDocument()); fireEvent.change(screen.getByLabelText('قیمت ویزیت'), { target: { value: '50000' } }); fireEvent.click(screen.getByText('ایجاد سرویس')); await waitFor(() => expect(post).toHaveBeenCalled()); expect(post.mock.calls[0][0]).toBe('/api/v1/patient/r-1/session'); expect(post.mock.calls[0][1]).toMatchObject({ visit_price_rials: 500_000 }); }); it('فلگ غیرفعال + قیمت صفر → رفتار قبلی: ثبت مجاز', async () => { mockEndpoints({ free_visit_price_rials: 0, require_visit_price: false }); const onCreated = renderStep(); await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/insurance-pricing')); fireEvent.click(screen.getByText('ایجاد سرویس')); await waitFor(() => expect(post).toHaveBeenCalled()); expect(post.mock.calls[0][1]).toMatchObject({ visit_price_rials: 0 }); await waitFor(() => expect(onCreated).toHaveBeenCalledWith('s-1')); expect(screen.queryByText('هزینه ویزیت الزامی است')).not.toBeInTheDocument(); }); it('پیش‌فرض قیمت از «قیمت ویزیت آزاد» پر می‌شود (ریال → تومان)', async () => { mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false }); renderStep(); await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000')); }); }); describe('patientShareOf — آینه‌ی BillingCalculator', () => { it('سناریوی مرجع: ۳۰٪ پوشش پایه روی ۵,۹۵۲,۰۰۰ ریال', () => { const share = patientShareOf(5_952_000, { covered: true, percent: 30, franchise: 0, ceiling: null }, null); expect(share).toBe(4_166_400); }); it('فرانشیز بیمهٔ پایه سهم بیمار را زیاد نمی‌کند', () => { const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, null); expect(share).toBe(0); }); it('فرانشیز بیمهٔ تکمیلی به سهم بیمار اضافه می‌شود', () => { const share = patientShareOf( 600_000, { covered: true, percent: 70, franchise: 90_000, ceiling: null }, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, ); expect(share).toBe(50_000); }); it('سقف تعهد سهم بیمه را محدود می‌کند', () => { const share = patientShareOf(600_000, { covered: true, percent: 70, franchise: 0, ceiling: 300_000 }, null); expect(share).toBe(300_000); }); it('notCovered → کل مبلغ سهم بیمار', () => { const share = patientShareOf(600_000, { covered: false, percent: 0, franchise: 0, ceiling: null }, null); expect(share).toBe(600_000); }); }); // ── بیمه: نوع خدمت در ثبت/ویرایش مراجعه ────────────────────────────────────── const BASIC_CONTRACT = { uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null, category_coverages: { outpatient: 70, inpatient: 30 }, }; /** پروفایلِ بیمار با بیمهٔ پایه، تا بلوک بیمه نمایش داده شود. */ const insuredProfile = { basic_insurance_id: 3 } as never; function mockInsuranceEndpoints(enabled: string[]) { const categories = [ { key: 'outpatient', label: 'خدمات سرپایی', enabled: enabled.includes('outpatient') }, { key: 'inpatient', label: 'خدمات بستری', enabled: enabled.includes('inpatient') }, ]; get.mockImplementation((url: string) => { if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: { free_visit_price_rials: 5_952_000, require_visit_price: false, service_categories: categories, default_service_category: enabled.length === 1 ? enabled[0] : null, } }); if (url === '/api/v1/inventory-items') return Promise.resolve({ success: true, data: { items: [] } }); if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [BASIC_CONTRACT] } }); return Promise.resolve({ success: true, data: [] }); }); } /** react-select: منو با ArrowDown باز می‌شود و گزینه با role=option انتخاب می‌شود. */ async function pick(selectLabel: string, optionText: string) { fireEvent.keyDown(screen.getByRole('combobox', { name: selectLabel }), { key: 'ArrowDown' }); fireEvent.click(await screen.findByRole('option', { name: optionText })); } describe('CreateStep — نوع خدمت بیمه', () => { it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => { mockInsuranceEndpoints(['outpatient', 'inpatient']); renderWithProviders( {}} />, ); expect(await screen.findByText('نوع خدمت')).toBeInTheDocument(); }); it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => { mockInsuranceEndpoints(['outpatient']); renderWithProviders( {}} />, ); // ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان (ورودی قیمت ویزیت رقم خام است) await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('595200')); expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument(); }); it('انتخاب نوع خدمت، درصد پوشش همان نوع را روی فرم می‌گذارد و ارسال می‌کند', async () => { mockInsuranceEndpoints(['outpatient', 'inpatient']); renderWithProviders( {}} />, ); await screen.findByText('نوع خدمت'); await pick('بدون بیمه پایه', 'بیمه ایران'); await pick('انتخاب نوع خدمت', 'خدمات بستری'); // درصد بستری = ۳۰ await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30')); fireEvent.click(screen.getByText('ایجاد سرویس')); await waitFor(() => expect(post).toHaveBeenCalled()); expect(post.mock.calls[0][1]).toMatchObject({ insurance_base_id: 3, insurance_service_category: 'inpatient', base_insurance_discount_percent: 30, }); }); }); describe('CreateStep — نمایش و پیش‌انتخاب بیمه', () => { it('با داشتن قرارداد بیمه، بلوک بیمه نمایش داده می‌شود (بدون بیمهٔ پروفایل)', async () => { mockInsuranceEndpoints(['outpatient', 'inpatient']); renderWithProviders( {}} />, ); expect(await screen.findByText('بیمه پایه')).toBeInTheDocument(); expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument(); }); it('بدون هیچ قراردادی، بلوک بیمه نمایش داده نمی‌شود', async () => { mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false }); renderWithProviders( {}} />, ); await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000')); expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument(); }); it('مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیش‌انتخاب می‌شود', async () => { mockInsuranceEndpoints(['outpatient']); renderWithProviders( {}} />, ); // بیمهٔ پروفایل (id=3) قرارداد فعال دارد → انتخاب و درصد سرپایی ۷۰ expect(await screen.findByText('بیمه ایران')).toBeInTheDocument(); await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70')); }); it('ویرایش مراجعه: بیمهٔ ثبت‌شده مشخص و قابل تغییر است', async () => { mockInsuranceEndpoints(['outpatient', 'inpatient']); const editSession = { uuid: 's-9', visit_price_rials: 5_952_000, session_at: 1_700_000_000, insurance_base_id: 3, base_insurance_discount_percent: 30, insurance_service_category: 'inpatient', services: [], consumables: [], } as never; renderWithProviders( {}} />, ); expect(await screen.findByText('بیمه ایران')).toBeInTheDocument(); expect(screen.getByText('خدمات بستری')).toBeInTheDocument(); expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30'); // قابل تغییر: انتخاب نوع سرپایی درصد را به ۷۰ می‌برد await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70')); }); });