- Adjusted the calculation of payable amounts in PaymentStep to align with server logic, ensuring overpayments are handled correctly. - Enhanced DetailsStep to include consumables in the itemized cost breakdown, ensuring consistency with patient share calculations. - Updated tests for SessionPaymentPage to validate new behavior regarding overpayments and consumable listings. - Modified PatientController to register SessionPayment correctly when settling sessions via wallet, preventing double charges. - Refactored WalletService to remove outdated methods and ensure wallet transactions reflect the correct amounts after discounts. - Improved accessibility in SearchableSelect component by adding aria labels and ensuring proper role attributes for screen readers. - Updated styles to ensure minimum touch targets meet WCAG guidelines for mobile usability.
212 lines
9.5 KiB
TypeScript
212 lines
9.5 KiB
TypeScript
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<typeof vi.fn>;
|
|
const post = api.post as ReturnType<typeof vi.fn>;
|
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
|
|
|
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(
|
|
<Routes>
|
|
<Route path="/admin/patients/:recordUuid/session/:sessionUuid/pay" element={<SessionPaymentPage />} />
|
|
</Routes>,
|
|
{ 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();
|
|
});
|
|
});
|