fix(appointment): ensure appointment details are always fetched from the server to avoid incorrect pricing

This commit is contained in:
hamed
2026-08-10 12:21:28 +03:30
parent 7073377122
commit bb2dbc3371
2 changed files with 119 additions and 23 deletions
@@ -29,8 +29,12 @@ function render() {
beforeEach(() => {
vi.clearAllMocks();
// مودال همیشه جزئیات نوبت را از سرور می‌گیرد — ردیفِ فهرست مبلغ ویزیت ندارد.
get.mockImplementation((url: string) =>
url.startsWith('/api/v1/appointment/')
? Promise.resolve({ success: true, data: appointment })
// payment-methods (pos / bank-accounts) و بقیهٔ GETها
get.mockResolvedValue({ success: true, data: [] });
: Promise.resolve({ success: true, data: [] }));
post.mockResolvedValue({ success: true, data: {} });
});
@@ -45,6 +49,14 @@ function amountInputs() {
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
}
/**
* مودال تا رسیدنِ جزئیات نوبت از سرور، دکمه‌هایش قفل است — مبلغ نباید از ردیفِ
* ناقصِ فهرست خوانده شود. هر تست اول منتظر همین می‌ماند.
*/
async function settle() {
await waitFor(() => expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).not.toBeDisabled());
}
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» می‌رود و ردیف‌های مبلغ را برمی‌گرداند. */
function goToPayment() {
nextStep();
@@ -57,31 +69,35 @@ function goToReview() {
}
describe('ConfirmAppointmentModal', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', async () => {
render();
await settle();
expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
expect(screen.getByText('ویزیت')).toBeInTheDocument();
expect(screen.getByText('لیزر')).toBeInTheDocument();
expect(screen.getByText('جمع کل')).toBeInTheDocument();
});
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', () => {
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', async () => {
render();
await settle();
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
goToReview();
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
});
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', () => {
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
});
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', () => {
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', async () => {
render();
await settle();
const rows = goToPayment();
fireEvent.change(rows[0], { target: { value: '9000000' } });
@@ -96,6 +112,7 @@ describe('ConfirmAppointmentModal', () => {
it('پرداخت جزئی مجاز است و همان یک روش را ثبت می‌کند', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
@@ -108,6 +125,7 @@ describe('ConfirmAppointmentModal', () => {
it('تقسیم پرداخت بین دو روش: مجموع ردیف‌ها به‌صورت آرایه ثبت می‌شود', async () => {
render();
await settle();
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم می‌کنیم
fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
// افزودن روش دوم — پیش‌فرض با باقی‌ماندهٔ ۱۰۰٬۰۰۰ تومان پر می‌شود
@@ -131,8 +149,9 @@ describe('ConfirmAppointmentModal', () => {
}));
});
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', () => {
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', async () => {
render();
await settle();
goToPayment();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
expect(amountInputs()).toHaveLength(2);
@@ -162,12 +181,20 @@ const SUPP_CONTRACT = {
category_coverages: { outpatient: 90, inpatient: 60 },
};
/**
* @param detail نوبتی که اندپوینت جزئیات برمی‌گرداند — مودال همیشه آن را می‌خواند،
* چون ردیفِ فهرست مبلغ ویزیت و بیمه ندارد.
*/
function mockInsurance(
categories: { key: string; label: string; enabled: boolean }[],
freeVisitPriceRials = 0,
contracts: unknown[] = [CONTRACT],
detail: unknown = referenceAppointment,
) {
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/appointment/')) {
return Promise.resolve({ success: true, data: detail });
}
if (url === '/api/v1/insurance-pricing') {
return Promise.resolve({
success: true,
@@ -211,6 +238,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
@@ -222,6 +250,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
{ key: 'inpatient', label: 'خدمات بستری', enabled: false },
]);
renderReference();
await settle();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
@@ -230,6 +259,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
@@ -239,6 +269,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال می‌شوند (سرپایی ۷۰٪)', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -261,6 +292,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمی‌شود', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
@@ -269,6 +301,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('سهم پایه و تکمیلی جدا و زنجیره‌ای محاسبه می‌شوند و هر دو ارسال می‌گردند', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -296,6 +329,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال می‌شود', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -308,15 +342,17 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
});
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => {
mockInsurance(BOTH, 5_952_000);
const priceless = { uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] };
mockInsurance(BOTH, 5_952_000, [CONTRACT], priceless);
renderWithProviders(
<ConfirmAppointmentModal
open
appointmentUuid="a1"
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }}
appointment={priceless}
onClose={() => {}}
/>,
);
await settle();
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد.
@@ -336,6 +372,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('نوع بستری درصد خودش را می‌گیرد (۳۰٪)', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات بستری');
@@ -353,8 +390,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li'))
.map(li => li.textContent?.replace(/^\d+/, '').trim());
it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', () => {
it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', async () => {
render();
await settle();
expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']);
// جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه.
@@ -362,16 +400,18 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
expect(screen.queryByPlaceholderText('0')).toBeNull();
});
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', () => {
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', async () => {
render();
await settle();
expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
});
it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', () => {
it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' }));
@@ -382,8 +422,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰');
});
it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', () => {
it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
@@ -398,8 +439,9 @@ describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () =>
const percentButton = (percent: string) =>
screen.getAllByRole('button', { name: `${percent}٪` })[0];
it('کلیک روی هر درصد، همان کسر از سهم بیمار را در مبلغ می‌گذارد', () => {
it('کلیک روی هر درصد، همان کسر از سهم بیمار را در مبلغ می‌گذارد', async () => {
render();
await settle();
const [amount] = goToPayment();
fireEvent.click(percentButton('۲۰'));
@@ -417,6 +459,7 @@ describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () =>
it('درصدِ اعمال‌شده تا مرحلهٔ ثبت می‌ماند و به سرور می‌رسد', async () => {
render();
await settle();
goToPayment();
fireEvent.click(percentButton('۵۰'));
@@ -429,8 +472,9 @@ describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () =>
]);
});
it('هر ردیف درصد خودش را دارد؛ ردیف دیگر دست نمی‌خورد', () => {
it('هر ردیف درصد خودش را دارد؛ ردیف دیگر دست نمی‌خورد', async () => {
render();
await settle();
goToPayment();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
@@ -445,17 +489,63 @@ describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () =>
expect(rows[1]).toHaveValue('۶۰٬۰۰۰');
});
it('نوبتِ بدون هزینه اصلاً دکمهٔ درصدی ندارد', () => {
it('نوبتِ بدون هزینه اصلاً دکمهٔ درصدی ندارد', async () => {
const free = { uuid: 'a2', version: 1, visit_price_rials: 0, service_items: [] };
// جزئیات هم باید همان نوبتِ بی‌هزینه باشد؛ مودال مبلغ را از سرور می‌خواند.
get.mockImplementation((url: string) =>
Promise.resolve({ success: true, data: url.startsWith('/api/v1/appointment/') ? free : [] }));
renderWithProviders(
<ConfirmAppointmentModal
open
appointmentUuid="a2"
appointment={{ uuid: 'a2', version: 1, visit_price_rials: 0, service_items: [] }}
appointment={free}
onClose={() => {}}
/>,
);
await settle();
nextStep();
expect(screen.queryByRole('button', { name: '۵۰٪' })).not.toBeInTheDocument();
});
});
/**
* باگ واقعی: تایم‌لاین ردیفِ فهرست نوبت‌ها را پاس می‌داد و آن ردیف اصلاً
* `visit_price_rials` ندارد. مودال به همان اعتماد می‌کرد، «۰ تومان» نشان می‌داد و
* همان صفر را ثبت می‌کرد — هزینهٔ ویزیتِ ثبت‌شده در نوبت نادیده می‌ماند.
*/
describe('ConfirmAppointmentModal — ردیفِ ناقصِ فهرست', () => {
/** دقیقاً شکل ردیفِ `/api/v1/my/appointments`: بدون مبلغ و بدون بیمه. */
const listRow = {
uuid: 'a1',
version: 1,
patient_name: 'محمد رضایی',
service_items: [],
};
it('مبلغ را از جزئیات سرور می‌گیرد، نه از ردیفِ پاس‌داده‌شده', async () => {
renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={listRow} onClose={() => {}} />,
);
await settle();
// ۳٬۰۰۰٬۰۰۰ ریالِ جزئیات = ۳۰۰٬۰۰۰ تومان، نه صفرِ ردیفِ فهرست.
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
});
it('تا نیامدنِ جزئیات، رفتن به مرحلهٔ پرداخت قفل است', () => {
let resolveDetail: (v: unknown) => void = () => {};
get.mockImplementation((url: string) =>
url.startsWith('/api/v1/appointment/')
? new Promise((resolve) => { resolveDetail = resolve; })
: Promise.resolve({ success: true, data: [] }));
renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={listRow} onClose={() => {}} />,
);
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
resolveDetail({ success: true, data: appointment });
});
});
@@ -119,12 +119,14 @@ export default function ConfirmAppointmentModal({
const [touched, setTouched] = useState(false);
const [stepIdx, setStepIdx] = useState(0);
// وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم:
// مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند.
// جزئیات همیشه گرفته می‌شود، حتی وقتی صفحهٔ میزبان نوبتی پاس داده است: ردیفِ
// فهرست نوبت‌ها `visit_price_rials` و بیمه را ندارد، و مودال با اعتماد به همان
// ردیف، هزینهٔ ویزیتِ ثبت‌شده را صفر نشان می‌داد و همان صفر را هم ثبت می‌کرد.
// مبلغ چیزی نیست که از یک payload ناقص حدس زده شود.
const detailQuery = useQuery({
queryKey: ['appointment', appointmentUuid],
queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`),
enabled: open && !appointment,
enabled: open,
});
// روش‌های پرداختِ ثبت‌شده — فقط وقتی مودال باز است.
@@ -151,8 +153,10 @@ export default function ConfirmAppointmentModal({
[bankQuery.data],
);
const appt: AppointmentLike | null = appointment
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null);
// پاسخ سرور مرجع است؛ نوبتِ پاس‌داده‌شده فقط تا رسیدنِ آن، صفحه را خالی نگه نمی‌دارد.
const detail: AppointmentLike | null =
(detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null;
const appt: AppointmentLike | null = detail ?? appointment ?? null;
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
const insurance = useAppointmentInsurance(open);
@@ -278,7 +282,9 @@ export default function ConfirmAppointmentModal({
onClose();
}
const loading = detailQuery.isLoading && !appointment;
// تا رسیدنِ جزئیات، دکمه‌ها قفل‌اند: کاربر نباید روی مبلغی که هنوز از سرور نیامده
// «مرحلهٔ بعد» بزند و پرداختِ صفر ثبت کند.
const loading = detailQuery.isLoading;
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
const isLastStep = currentStep === 'review';