An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170 lines
8.0 KiB
TypeScript
170 lines
8.0 KiB
TypeScript
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 {},
|
|
}));
|
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
|
|
|
import { api } from '../lib/api';
|
|
import TenantInsuranceContracts, { filterInsurances, contractSummary } from './TenantInsuranceContracts';
|
|
import type { Contract } from './InsuranceModal';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
|
|
|
const mk = (over: Partial<Contract>): Contract => ({
|
|
uuid: 'u', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
|
|
version: 1, is_active: true, coverage_percent: 70, franchise_rials: 0,
|
|
annual_ceiling_rials: null, kind: 'basic', effective_from: 0, effective_to: null, ...over,
|
|
});
|
|
|
|
describe('filterInsurances', () => {
|
|
const list = [
|
|
mk({ uuid: 'a', insurance_id: 3, insurance_name: 'بیمه ایران' }),
|
|
mk({ uuid: 'b', insurance_id: 5, insurance_name: 'بیمه آسیا' }),
|
|
];
|
|
it('filters by name', () => {
|
|
expect(filterInsurances(list, 'آسیا')).toHaveLength(1);
|
|
expect(filterInsurances(list, 'آسیا')[0].uuid).toBe('b');
|
|
});
|
|
it('filters by code (insurance_id)', () => {
|
|
expect(filterInsurances(list, '5')).toHaveLength(1);
|
|
});
|
|
it('empty query returns all', () => {
|
|
expect(filterInsurances(list, ' ')).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
const categories = [
|
|
{ key: 'outpatient', label: 'سرپایی' },
|
|
{ key: 'inpatient', label: 'بستری' },
|
|
];
|
|
|
|
describe('contractSummary', () => {
|
|
it('breaks the coverage down per service category', () => {
|
|
const s = contractSummary(
|
|
mk({ category_coverages: { outpatient: 70, inpatient: 30 }, annual_ceiling_rials: 20_000_000 }),
|
|
categories,
|
|
);
|
|
expect(s).toContain('سرپایی ۷۰٪');
|
|
expect(s).toContain('بستری ۳۰٪');
|
|
expect(s).toContain('سقف پوشش');
|
|
});
|
|
|
|
it('shows the franchise only on a supplementary contract', () => {
|
|
const basic = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'basic' }), categories);
|
|
expect(basic).not.toContain('فرانشیز');
|
|
|
|
const supp = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'supplementary' }), categories);
|
|
expect(supp).toContain('فرانشیز');
|
|
});
|
|
|
|
it('marks an unlimited ceiling', () => {
|
|
const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }), categories);
|
|
expect(s).toContain('سقف پوشش نامحدود');
|
|
});
|
|
|
|
it('falls back to the legacy contract percent with no category rows', () => {
|
|
const s = contractSummary(mk({ coverage_percent: 90, category_coverages: undefined }), categories);
|
|
expect(s).toContain('پوشش ۹۰٪');
|
|
});
|
|
|
|
/** تا برچسبهای فارسی از سرور نرسیدهاند، نباید کلید انگلیسی نشان داده شود. */
|
|
it('بدون برچسبهای سرور، کلید انگلیسی نشان نمیدهد', () => {
|
|
const s = contractSummary(mk({ coverage_percent: 90, category_coverages: { outpatient: 10, inpatient: 30 } }), []);
|
|
expect(s).not.toContain('outpatient');
|
|
expect(s).not.toContain('inpatient');
|
|
expect(s).toContain('پوشش ۹۰٪');
|
|
});
|
|
});
|
|
|
|
describe('TenantInsuranceContracts', () => {
|
|
beforeEach(() => {
|
|
get.mockReset();
|
|
patch.mockReset();
|
|
patch.mockResolvedValue({ success: true, data: { data: {} } });
|
|
get.mockImplementation((path: string) => {
|
|
if (path.includes('tenant-insurances')) {
|
|
return Promise.resolve({ success: true, data: { data: [
|
|
mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true }),
|
|
mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', insurance_kind: 'basic', is_active: false }),
|
|
mk({ uuid: 'u3', insurance_id: 7, insurance_name: 'بیمه دانا', insurance_kind: 'supplementary', is_active: true }),
|
|
] } });
|
|
}
|
|
return Promise.resolve({ success: true, data: { insurances: [] } });
|
|
});
|
|
});
|
|
|
|
// Desktop table and mobile cards both render in jsdom (CSS `hidden`/`md:` is inert),
|
|
// so each row's text appears twice — assertions use *AllBy* accordingly.
|
|
it('lists only the active tab (basic) contracts', async () => {
|
|
renderWithProviders(<TenantInsuranceContracts />);
|
|
expect((await screen.findAllByText('بیمه ایران')).length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0);
|
|
// Supplementary contract is hidden under the other tab.
|
|
expect(screen.queryByText('بیمه دانا')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('switching to the supplementary tab filters by kind', async () => {
|
|
renderWithProviders(<TenantInsuranceContracts />);
|
|
await screen.findAllByText('بیمه ایران');
|
|
fireEvent.click(screen.getByRole('tab', { name: /بیمه تکمیلی/ }));
|
|
expect((await screen.findAllByText('بیمه دانا')).length).toBeGreaterThan(0);
|
|
expect(screen.queryByText('بیمه ایران')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('classifies by catalog type, overriding a stale contract kind', async () => {
|
|
// آسیا is stored on the contract as basic (legacy manual pick) but the catalog
|
|
// marks it supplementary — catalog type wins, so it must leave the basic tab.
|
|
get.mockImplementation((path: string) => {
|
|
if (path.includes('tenant-insurances')) {
|
|
return Promise.resolve({ success: true, data: { data: [
|
|
mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic' }),
|
|
mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', insurance_kind: 'basic' }),
|
|
] } });
|
|
}
|
|
return Promise.resolve({ success: true, data: { insurances: [
|
|
{ insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' },
|
|
{ insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' },
|
|
] } });
|
|
});
|
|
renderWithProviders(<TenantInsuranceContracts />);
|
|
await screen.findByText('بیمه ایران');
|
|
expect(screen.queryByText('بیمه آسیا')).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('tab', { name: /بیمه تکمیلی/ }));
|
|
expect(await screen.findByText('بیمه آسیا')).toBeInTheDocument();
|
|
});
|
|
|
|
it('search box filters rows by name', async () => {
|
|
renderWithProviders(<TenantInsuranceContracts />);
|
|
await screen.findAllByText('بیمه ایران');
|
|
fireEvent.change(screen.getByPlaceholderText('جستجو در بیمه ها...'), { target: { value: 'آسیا' } });
|
|
expect(screen.queryByText('بیمه ایران')).not.toBeInTheDocument();
|
|
expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('clicking a row expands its detail, clicking again collapses', async () => {
|
|
renderWithProviders(<TenantInsuranceContracts />);
|
|
const names = await screen.findAllByText('بیمه ایران');
|
|
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
|
|
fireEvent.click(names[0]);
|
|
expect(screen.getAllByText('نسخه قرارداد').length).toBeGreaterThan(0);
|
|
fireEvent.click(names[0]);
|
|
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('clicking the status toggle does not expand the row (stopPropagation)', async () => {
|
|
renderWithProviders(<TenantInsuranceContracts />);
|
|
await screen.findAllByText('بیمه ایران');
|
|
const toggles = screen.getAllByLabelText('غیرفعال کردن');
|
|
fireEvent.click(toggles[0]);
|
|
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
|
|
await waitFor(() =>
|
|
expect(patch).toHaveBeenCalledWith('/api/v1/billing/tenant-insurances/u1', { is_active: false }),
|
|
);
|
|
});
|
|
});
|