// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import { MantineProvider } from '@mantine/core';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MedplumProvider } from '@medplum/react';
import type { ChargeItem, CodeableConcept } from '@medplum/fhirtypes';
import { MockClient } from '@medplum/mock';
import { describe, expect, test, beforeEach, vi } from 'vitest';
import ChargeItemPanel from './ChargeItemPanel';
import * as chargeItemsUtils from '../../utils/chargeitems';
const mockChargeItem: ChargeItem = {
resourceType: 'ChargeItem',
id: 'charge-123',
status: 'billable',
subject: { reference: 'Patient/patient-123' },
code: {
coding: [
{
system: 'http://www.ama-assn.org/go/cpt',
code: '99213',
display: 'Office Visit',
},
],
},
priceOverride: {
value: 100,
currency: 'USD',
},
};
vi.mock('../../utils/chargeitems', () => ({
applyChargeItemDefinition: vi.fn(),
}));
describe('ChargeItemPanel', () => {
let medplum: MockClient;
beforeEach(() => {
medplum = new MockClient();
vi.clearAllMocks();
vi.spyOn(chargeItemsUtils, 'applyChargeItemDefinition').mockImplementation(async (_, item) => item);
medplum.valueSetExpand = vi
.fn()
.mockImplementation(async (params: { url: string; filter?: string; count?: number }) => {
if (params.url === 'http://hl7.org/fhir/ValueSet/claim-modifiers') {
const allModifiers = [
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: '25',
display: 'Significant, separately identifiable evaluation and management service',
},
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: '26',
display: 'Professional Component',
},
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: 'a',
display: 'Repair of prior service or installation',
},
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: 'b',
display: 'Temporary service or installation',
},
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: 'c',
display: 'TMJ treatment',
},
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: 'e',
display: 'Implant or associated with an implant',
},
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: 'rooh',
display: 'Rush or Outside of office hours',
},
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: 'x',
display: 'None',
},
];
// Filter modifiers if filter parameter is provided
const filterLower = params.filter?.toLowerCase() ?? '';
const filtered = params.filter
? allModifiers.filter(
(m) => m.code.toLowerCase().includes(filterLower) || m.display.toLowerCase().includes(filterLower)
)
: allModifiers;
// Limit results based on count parameter
const limited = params.count ? filtered.slice(0, params.count) : filtered;
return {
resourceType: 'ValueSet',
id: '9a6769a5-e60e-415b-ac35-8ed865336521',
url: 'http://hl7.org/fhir/ValueSet/claim-modifiers',
version: '4.0.1',
name: 'ModifierTypeCodes',
title: 'Modifier type Codes',
status: 'draft',
expansion: {
total: allModifiers.length,
timestamp: '2025-12-23T20:45:54.336Z',
contains: limited,
},
};
}
// Default response for other ValueSets
return {
resourceType: 'ValueSet',
expansion: {
contains: [],
},
};
});
});
const setup = (props: Partial<Parameters<typeof ChargeItemPanel>[0]> = {}): ReturnType<typeof render> => {
return render(
<MedplumProvider medplum={medplum}>
<MantineProvider>
<ChargeItemPanel chargeItem={mockChargeItem} onChange={vi.fn()} onDelete={vi.fn()} {...props} />
</MantineProvider>
</MedplumProvider>
);
};
test('renders charge item details', () => {
setup();
expect(screen.getByText('CPT Code')).toBeInTheDocument();
expect(screen.getByDisplayValue('$100.00')).toBeInTheDocument();
expect(screen.getByText('Calculated Price')).toBeInTheDocument();
});
test('renders modifiers input', () => {
setup();
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
test('handles delete charge item', async () => {
const onDelete = vi.fn();
const user = userEvent.setup();
vi.spyOn(medplum, 'deleteResource').mockResolvedValue({ resourceType: 'ChargeItem' } as ChargeItem);
setup({ onDelete });
const buttons = screen.getAllByRole('button', { hidden: true });
const deleteMenuButton = buttons.find((btn) => btn.getAttribute('aria-haspopup') === 'menu');
if (!deleteMenuButton) {
throw new Error('Delete menu button not found');
}
await user.click(deleteMenuButton);
const deleteButton = await screen.findByText('Delete');
await user.click(deleteButton);
await waitFor(() => {
expect(medplum.deleteResource).toHaveBeenCalledWith('ChargeItem', 'charge-123');
expect(onDelete).toHaveBeenCalledWith(mockChargeItem);
});
});
test('displays updated price', () => {
const itemWithPrice: ChargeItem = {
...mockChargeItem,
priceOverride: { value: 150.5, currency: 'USD' },
};
setup({ chargeItem: itemWithPrice });
expect(screen.getByDisplayValue('$150.50')).toBeInTheDocument();
});
test('displays price as N/A when no price override', () => {
const itemWithoutPrice: ChargeItem = {
...mockChargeItem,
priceOverride: undefined,
};
setup({ chargeItem: itemWithoutPrice });
expect(screen.getByDisplayValue('N/A')).toBeInTheDocument();
});
test('displays modifier when present', () => {
const itemWithModifier: ChargeItem = {
...mockChargeItem,
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/chargeitem-modifier',
valueCodeableConcept: {
coding: [{ code: '26', display: 'Professional Component' }],
},
},
],
};
setup({ chargeItem: itemWithModifier });
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
test('renders price calculation explanation', () => {
setup();
expect(
screen.getByText(/Price calculated from Price chart, taking into account applied modifiers/)
).toBeInTheDocument();
});
test('updates modifiers with value', async () => {
const onChange = vi.fn();
const updatedModifier: CodeableConcept = {
coding: [
{
code: '25',
display: 'Significant, separately identifiable evaluation and management service',
},
],
};
const updatedChargeItem: ChargeItem = {
...mockChargeItem,
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/chargeitem-modifier',
valueCodeableConcept: updatedModifier,
},
],
};
vi.spyOn(medplum, 'updateResource').mockResolvedValue(updatedChargeItem as any);
setup({ onChange });
// Modifiers input is present but interaction requires CodeableConceptInput
// which is complex to test. We verify the component structure.
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
test('handles charge item with existing modifier extension', () => {
const itemWithExistingModifier: ChargeItem = {
...mockChargeItem,
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/chargeitem-modifier',
valueCodeableConcept: {
coding: [{ code: '26', display: 'Professional Component' }],
},
},
{
url: 'http://example.com/other-extension',
valueString: 'other value',
},
],
};
setup({ chargeItem: itemWithExistingModifier });
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
test('handles charge item with non-CPT codes', () => {
const itemWithMultipleCodes: ChargeItem = {
...mockChargeItem,
code: {
coding: [
{
system: 'http://www.ama-assn.org/go/cpt',
code: '99213',
display: 'Office Visit',
},
{
system: 'http://example.com/other-system',
code: 'OTHER',
display: 'Other Code',
},
],
},
};
setup({ chargeItem: itemWithMultipleCodes });
expect(screen.getByText('CPT Code')).toBeInTheDocument();
});
test('updates price when chargeItem prop changes', () => {
const { rerender } = setup({ chargeItem: mockChargeItem });
expect(screen.getByDisplayValue('$100.00')).toBeInTheDocument();
const updatedItem: ChargeItem = {
...mockChargeItem,
priceOverride: { value: 200, currency: 'USD' },
};
rerender(
<MedplumProvider medplum={medplum}>
<MantineProvider>
<ChargeItemPanel chargeItem={updatedItem} onChange={vi.fn()} onDelete={vi.fn()} />
</MantineProvider>
</MedplumProvider>
);
expect(screen.getByDisplayValue('$200.00')).toBeInTheDocument();
});
test('handles charge item without extension', () => {
const itemWithoutExtension: ChargeItem = {
...mockChargeItem,
extension: undefined,
};
setup({ chargeItem: itemWithoutExtension });
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
test('calls updateModifiers and updateChargeItem when modifier is added', async () => {
const onChange = vi.fn();
const newModifier: CodeableConcept = {
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: '25',
display: 'Significant, separately identifiable evaluation and management service',
},
],
};
const updatedChargeItem: ChargeItem & { id: string } = {
...mockChargeItem,
id: 'charge-123',
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/chargeitem-modifier',
valueCodeableConcept: newModifier,
},
],
};
vi.spyOn(medplum, 'updateResource').mockResolvedValue(updatedChargeItem);
vi.spyOn(chargeItemsUtils, 'applyChargeItemDefinition').mockResolvedValue(updatedChargeItem);
setup({ onChange });
await waitFor(() => {
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
// Find the modifiers searchbox and interact with it
const modifierInputs = screen.getAllByRole('searchbox');
const modifierInput = modifierInputs.find((input) => {
const label = input.closest('.mantine-InputWrapper-root')?.querySelector('label');
return label?.textContent?.includes('Modifiers');
});
if (modifierInput) {
const user = userEvent.setup();
await user.type(modifierInput, '25');
// Wait for valueSetExpand to be called (it may be called multiple times due to debouncing)
await waitFor(
() => {
expect(medplum.valueSetExpand).toHaveBeenCalled();
const calls = vi.mocked(medplum.valueSetExpand).mock.calls;
const claimModifiersCall = calls.find(
(call) => call[0]?.url === 'http://hl7.org/fhir/ValueSet/claim-modifiers'
);
expect(claimModifiersCall).toBeDefined();
},
{ timeout: 5000 }
);
// Select option
await act(async () => {
fireEvent.keyDown(modifierInput, { key: 'ArrowDown', code: 'ArrowDown' });
fireEvent.keyDown(modifierInput, { key: 'Enter', code: 'Enter' });
});
await waitFor(() => {
expect(medplum.updateResource).toHaveBeenCalled();
expect(chargeItemsUtils.applyChargeItemDefinition).toHaveBeenCalled();
expect(onChange).toHaveBeenCalled();
});
}
});
test('calls updateModifiers with undefined to remove modifier', async () => {
const onChange = vi.fn();
const itemWithModifier: ChargeItem & { id: string } = {
...mockChargeItem,
id: 'charge-123',
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/chargeitem-modifier',
valueCodeableConcept: {
coding: [{ code: '26' }],
},
},
],
};
const updatedChargeItem: ChargeItem & { id: string } = {
...mockChargeItem,
id: 'charge-123',
extension: undefined,
};
vi.spyOn(medplum, 'updateResource').mockResolvedValue(updatedChargeItem);
vi.spyOn(chargeItemsUtils, 'applyChargeItemDefinition').mockResolvedValue(updatedChargeItem);
setup({ chargeItem: itemWithModifier, onChange });
await waitFor(() => {
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
// Find the clear button or trigger clearing the modifier
// The CodeableConceptInput should have a way to clear the value
const modifierInputs = screen.getAllByRole('searchbox');
const modifierInput = modifierInputs.find((input) => {
const label = input.closest('.mantine-InputWrapper-root')?.querySelector('label');
return label?.textContent?.includes('Modifiers');
});
if (modifierInput) {
// Try to clear the value by finding a clear button or simulating clearing
// This would trigger updateModifiers with undefined
const clearButtons = screen.queryAllByRole('button', { hidden: true });
const clearButton = clearButtons.find((btn) => {
const ariaLabel = btn.getAttribute('aria-label');
return ariaLabel?.toLowerCase().includes('clear') || ariaLabel?.toLowerCase().includes('remove');
});
if (clearButton) {
await act(async () => {
fireEvent.click(clearButton);
});
await waitFor(() => {
expect(medplum.updateResource).toHaveBeenCalled();
expect(chargeItemsUtils.applyChargeItemDefinition).toHaveBeenCalled();
expect(onChange).toHaveBeenCalled();
});
}
}
});
test('calls updateChargeItem which calls updateResource and applyChargeItemDefinition', async () => {
const onChange = vi.fn();
const updatedChargeItem: ChargeItem & { id: string } = {
...mockChargeItem,
id: 'charge-123',
priceOverride: { value: 150, currency: 'USD' },
};
vi.spyOn(medplum, 'updateResource').mockResolvedValue(updatedChargeItem);
vi.spyOn(chargeItemsUtils, 'applyChargeItemDefinition').mockResolvedValue(updatedChargeItem);
setup({ onChange });
// The updateChargeItem function is called internally by updateModifiers
// We verify it's set up correctly by checking the component renders
await waitFor(() => {
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
});
test('updateCptCodes preserves non-CPT codes when updating', () => {
const onChange = vi.fn();
const itemWithMultipleCodes: ChargeItem = {
...mockChargeItem,
code: {
coding: [
{
system: 'http://www.ama-assn.org/go/cpt',
code: '99213',
display: 'Office Visit',
},
{
system: 'http://example.com/other-system',
code: 'OTHER',
display: 'Other Code',
},
],
},
};
setup({ chargeItem: itemWithMultipleCodes, onChange });
// The updateCptCodes function preserves non-CPT codes
// We verify the component handles multiple codes correctly
expect(screen.getByText('CPT Code')).toBeInTheDocument();
});
test('updateModifiers replaces existing modifier extension', async () => {
const onChange = vi.fn();
const itemWithModifier: ChargeItem & { id: string } = {
...mockChargeItem,
id: 'charge-123',
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/chargeitem-modifier',
valueCodeableConcept: {
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: '26',
display: 'Professional Component',
},
],
},
},
],
};
const updatedChargeItem: ChargeItem & { id: string } = {
...mockChargeItem,
id: 'charge-123',
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/chargeitem-modifier',
valueCodeableConcept: {
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/modifiers',
code: '25',
display: 'Significant, separately identifiable evaluation and management service',
},
],
},
},
],
};
vi.spyOn(medplum, 'updateResource').mockResolvedValue(updatedChargeItem);
vi.spyOn(chargeItemsUtils, 'applyChargeItemDefinition').mockResolvedValue(updatedChargeItem);
setup({ chargeItem: itemWithModifier, onChange });
await waitFor(() => {
expect(screen.getByText('Modifiers')).toBeInTheDocument();
});
// Find the modifiers searchbox
const modifierInputs = screen.getAllByRole('searchbox');
const modifierInput = modifierInputs.find((input) => {
const label = input.closest('.mantine-InputWrapper-root')?.querySelector('label');
return label?.textContent?.includes('Modifiers');
});
expect(modifierInput).toBeDefined();
if (modifierInput) {
const user = userEvent.setup();
// Type to search for modifier '25'
await user.type(modifierInput, '25');
// Wait for valueSetExpand to be called
await waitFor(
() => {
expect(medplum.valueSetExpand).toHaveBeenCalled();
const calls = vi.mocked(medplum.valueSetExpand).mock.calls;
const claimModifiersCall = calls.find(
(call) => call[0]?.url === 'http://hl7.org/fhir/ValueSet/claim-modifiers'
);
expect(claimModifiersCall).toBeDefined();
},
{ timeout: 5000 }
);
// Select the modifier option
await act(async () => {
fireEvent.keyDown(modifierInput, { key: 'ArrowDown', code: 'ArrowDown' });
fireEvent.keyDown(modifierInput, { key: 'Enter', code: 'Enter' });
});
// Verify that updateResource was called with the updated modifier
await waitFor(() => {
expect(medplum.updateResource).toHaveBeenCalledWith(
expect.objectContaining({
id: 'charge-123',
extension: expect.arrayContaining([
expect.objectContaining({
url: 'http://hl7.org/fhir/StructureDefinition/chargeitem-modifier',
valueCodeableConcept: expect.objectContaining({
coding: expect.arrayContaining([
expect.objectContaining({
code: '25',
}),
]),
}),
}),
]),
})
);
});
// Verify that applyChargeItemDefinition was called
expect(chargeItemsUtils.applyChargeItemDefinition).toHaveBeenCalled();
// Verify that onChange was called with the applied charge item
expect(onChange).toHaveBeenCalledWith(updatedChargeItem);
}
});
});