import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } 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 { formatRial } from '../lib/utils'; import SessionPaymentPage from './SessionPaymentPage'; const get = api.get as ReturnType; const post = api.post as ReturnType; const patch = api.patch as ReturnType; const session = (over: object = {}) => ({ uuid: 's1', services: [{ service_name: 'کاشت مو' }], doctor_name: 'دکتر فتحی', visit_price_rials: 0, final_price_rials: 2_500_000, is_paid: false, patient_debt_rials: 2_300_000, discount_rials: 200_000, paid_total_rials: 0, payments: [] as object[], created_at: 1_700_000_000, ...over, }); function mockGets(sessions: object[]) { get.mockImplementation((url: string) => { if (url === '/api/v1/patient/r1') { return Promise.resolve({ success: true, data: { uuid: 'r1', user_name: 'ساغر صابری', profile: {} } }); } if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: sessions }); if (url === '/api/v1/patient/r1/wallet') { return Promise.resolve({ success: true, data: { balance_rials: 300_000, recent_transactions: [] } }); } return Promise.resolve({ success: true, data: [] }); }); } function renderPage() { return renderWithProviders( } /> , { route: '/admin/patients/r1/session/s1/pay' }, ); } beforeEach(() => { get.mockReset(); post.mockReset(); patch.mockReset(); }); describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => { it('renders payment step with real cost, wallet balance and remaining debt', async () => { mockGets([session()]); renderPage(); expect(await screen.findByText('هزینه سرویس:')).toBeInTheDocument(); expect(screen.getAllByText(formatRial(2_500_000)).length).toBeGreaterThan(0); // استپر دو گام حالت payment expect(screen.getByText('پرداخت')).toBeInTheDocument(); expect(screen.getByText('جزییات')).toBeInTheDocument(); // موجودی کیف پول expect(screen.getByText('موجودی کیف پول:')).toBeInTheDocument(); // چهار روش پرداخت tauri expect(screen.getByText('پرداخت از طریق کیف پول')).toBeInTheDocument(); expect(screen.getByText('پرداخت از طریق کارت خوان')).toBeInTheDocument(); expect(screen.getByText('پرداخت نقدی')).toBeInTheDocument(); expect(screen.getByText('کارت به کارت')).toBeInTheDocument(); // breadcrumb با نام بیمار expect(screen.getByText('ساغر صابری')).toBeInTheDocument(); }); it('submits a partial payment via the accordion (POST /session/{uuid}/payments)', async () => { mockGets([session()]); post.mockResolvedValue({ success: true, data: session({ paid_total_rials: 500_000 }) }); renderPage(); fireEvent.click(await screen.findByText('پرداخت نقدی')); fireEvent.change(screen.getByPlaceholderText('مبلغ (تومان)'), { target: { value: '500000' } }); fireEvent.click(screen.getByText('ثبت پرداخت')); await waitFor(() => expect(post).toHaveBeenCalledTimes(1)); const [url, body] = post.mock.calls[0]; expect(url).toBe('/api/v1/session/s1/payments'); // فیلد به تومان است و کامپوننت tomanToRial می‌کند: ۵۰۰,۰۰۰ تومان = ۵,۰۰۰,۰۰۰ ریال expect(body).toMatchObject({ method: 'cash', amount_rials: 5_000_000 }); expect(typeof (body as any).paid_at).toBe('number'); }); it('does not inflate «مبلغ نهایی قابل پرداخت» when the patient overpaid', async () => { // سرور remaining را در صفر کلمپ می‌کند. بازسازی از roo remaining+paid باعث // می‌شد مبلغ نهایی به اندازه‌ی پرداختی بالا برود و بیش‌پرداخت دیده نشود. mockGets([session({ remaining_rials: 0, paid_total_rials: 5_000_000 })]); renderPage(); await screen.findByText('مبلغ نهایی قابل پرداخت:'); // ۲,۵۰۰,۰۰۰ − ۲۰۰,۰۰۰ تخفیف = ۲,۳۰۰,۰۰۰ — نه ۵,۰۰۰,۰۰۰ expect(screen.getAllByText(formatRial(2_300_000)).length).toBeGreaterThan(0); expect(screen.queryByText(formatRial(5_000_000))).not.toBeInTheDocument(); }); it('lists consumables in the itemised cost breakdown', async () => { // کالای مصرفی در سهم بیمار حساب می‌شود، پس باید در ریز هزینه‌ها هم بیاید // وگرنه جمعِ خطوط با «جمع کل» نمی‌خواند. mockGets([session({ visit_price_rials: 500_000, consumables: [{ uuid: 'c1', item_name: 'عینک', quantity: 2, line_total_rials: 40_000 }], payments: [{ uuid: 'p1', method: 'cash', amount_rials: 100_000, paid_at: 1_700_000_000 }], })]); renderPage(); fireEvent.click(await screen.findByText('ثبت و ادامه')); expect(screen.getByText('عینک')).toBeInTheDocument(); expect(screen.getAllByText(formatRial(40_000)).length).toBeGreaterThan(0); }); it('shows insurance shares so the breakdown reconciles with the patient share', async () => { mockGets([session({ gross_total_rials: 4_000_000, base_insurance_rials: 1_000_000, supplementary_insurance_rials: 500_000, final_price_rials: 2_500_000, payments: [{ uuid: 'p1', method: 'cash', amount_rials: 100_000, paid_at: 1_700_000_000 }], })]); renderPage(); fireEvent.click(await screen.findByText('ثبت و ادامه')); expect(screen.getByText('جمع کل خدمات')).toBeInTheDocument(); expect(screen.getByText('سهم بیمه پایه')).toBeInTheDocument(); expect(screen.getByText('سهم بیمه تکمیلی')).toBeInTheDocument(); expect(screen.getByText('سهم بیمار')).toBeInTheDocument(); }); it('prefills the amount field with the remaining balance when a method is opened', async () => { // مانده ۲,۳۰۰,۰۰۰ ریال → ۲۳۰,۰۰۰ تومان در فیلد مبلغ mockGets([session({ remaining_rials: 2_300_000 })]); renderPage(); fireEvent.click(await screen.findByText('پرداخت نقدی')); expect(screen.getByPlaceholderText('مبلغ (تومان)')).toHaveValue('230,000'); }); it('caps the wallet prefill at the wallet balance', async () => { // موجودی کیف پول ۳۰۰,۰۰۰ ریال < مانده ۲,۳۰۰,۰۰۰ ریال → ۳۰,۰۰۰ تومان mockGets([session({ remaining_rials: 2_300_000 })]); renderPage(); fireEvent.click(await screen.findByText('پرداخت از طریق کیف پول')); expect(screen.getByPlaceholderText('مبلغ (تومان)')).toHaveValue('30,000'); }); it('applies and removes settlement discount (PATCH /session/{uuid})', async () => { mockGets([session()]); patch.mockResolvedValue({ success: true, data: session() }); renderPage(); await screen.findByText('هزینه سرویس:'); // حذف تخفیف → discount_rule_uuid: null (تخفیف قاعده‌محور را هم جدا می‌کند) fireEvent.click(screen.getByText('حذف تخفیف')); await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { discount_rule_uuid: null })); }); it('shows registered payments and moves to details step', async () => { mockGets([session({ payments: [ { uuid: 'p1', method: 'cash', amount_rials: 1_000_000, paid_at: 1_700_000_000 }, { uuid: 'p2', method: 'wallet', amount_rials: 300_000, paid_at: 1_700_000_000 }, ], paid_total_rials: 1_300_000, patient_debt_rials: 1_000_000, })]); renderPage(); // «پرداخت نقدی» هم عنوان آکاردئون است هم برچسب پرداخت ثبت‌شده expect((await screen.findAllByText('پرداخت نقدی')).length).toBeGreaterThanOrEqual(2); expect(screen.getByText('پرداخت از کیف پول')).toBeInTheDocument(); // گام جزییات fireEvent.click(screen.getByText('ثبت و ادامه')); expect(screen.getByText('صدور فاکتور')).toBeInTheDocument(); // نام سرویس هم در سرتیتر و هم در ریز هزینه‌ها می‌آید expect(screen.getAllByText('کاشت مو').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('دکتر فتحی')).toBeInTheDocument(); expect(screen.getByText('مبلغ باقی مانده:')).toBeInTheDocument(); }); it('renders empty payments state (پرداختی ثبت نشده است)', async () => { mockGets([session({ payments: [], discount_rials: 0 })]); renderPage(); expect(await screen.findByText('پرداختی ثبت نشده است')).toBeInTheDocument(); }); it('shows not-found message when session uuid does not exist', async () => { mockGets([session({ uuid: 'other' })]); renderPage(); expect(await screen.findByText('مراجعه یافت نشد')).toBeInTheDocument(); }); });