Base insurance is a percentage-only rule: patient share is now total minus the base share, and the contract franchise no longer inflates it (franchise stays meaningful for supplementary contracts only). Coverage percentages are managed centrally by admin per service category (outpatient/inpatient, extensible via the ServiceCategory enum). A tenant contract may override a category, otherwise it follows the admin default live — changing the central value immediately applies to every contract that did not override it. - add ServiceCategory enum + GET /api/v1/service-categories as the single source of the category list for every client - add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints) and expose coverage_defaults on the insurance list and insurance-pricing - add tenant_insurance_category_coverage; tenant-insurances accepts optional category_coverages (needs insurances.update) and returns the effective percentages with their source - add service_items.service_category; visits always resolve as outpatient - drop the reverse-engineered percent from patient_share_rials in MyPatientsPage and align the client-side BillingCalculator mirror in CreateStep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
7.5 KiB
TypeScript
162 lines
7.5 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('پوشش ۹۰٪');
|
|
});
|
|
});
|
|
|
|
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 }),
|
|
);
|
|
});
|
|
});
|