From 3d9f51a7aa033782ef04a37cf9a445badc160b05 Mon Sep 17 00:00:00 2001 From: Usama Idriss Kakumba Date: Wed, 6 Nov 2024 09:16:57 +0300 Subject: [PATCH] chore: add more tests --- ...ingLines.mock.ts => billing-lines.mock.ts} | 0 __mocks__/index.ts | 4 +- __mocks__/invoices-response.mock.ts | 25 + __mocks__/order-response.mock.ts | 31 ++ .../billing-status-summary.component.tsx | 2 +- .../billing-status-summary.test.tsx | 4 + src/resources/billing-status-summary.test.ts | 454 ++++++++++++++++++ src/resources/billing-status.resource.ts | 16 +- 8 files changed, 526 insertions(+), 10 deletions(-) rename __mocks__/{billingLines.mock.ts => billing-lines.mock.ts} (100%) create mode 100644 __mocks__/invoices-response.mock.ts create mode 100644 __mocks__/order-response.mock.ts create mode 100644 src/resources/billing-status-summary.test.ts diff --git a/__mocks__/billingLines.mock.ts b/__mocks__/billing-lines.mock.ts similarity index 100% rename from __mocks__/billingLines.mock.ts rename to __mocks__/billing-lines.mock.ts diff --git a/__mocks__/index.ts b/__mocks__/index.ts index 076548c..62e2ecb 100644 --- a/__mocks__/index.ts +++ b/__mocks__/index.ts @@ -1 +1,3 @@ -export * from './billingLines.mock'; +export * from './billing-lines.mock'; +export * from './invoices-response.mock'; +export * from './order-response.mock'; diff --git a/__mocks__/invoices-response.mock.ts b/__mocks__/invoices-response.mock.ts new file mode 100644 index 0000000..5a74e84 --- /dev/null +++ b/__mocks__/invoices-response.mock.ts @@ -0,0 +1,25 @@ +export const mockInvoicesResponse = { + data: [ + { + name: 'INV-001', + date: '2024-01-01', + payment_state: 'paid', + date_due: '2024-02-01', + invoice_lines: [{ id: 1 }], + }, + { + name: 'INV-002', + date: '2024-01-01', + payment_state: 'unpaid', + date_due: '2024-02-01', + invoice_lines: [{ id: 2 }], + }, + { + name: 'INV-003', + date: '2024-01-01', + payment_state: 'partial', + date_due: '2024-02-01', + invoice_lines: [{ id: 3 }], + }, + ], +}; diff --git a/__mocks__/order-response.mock.ts b/__mocks__/order-response.mock.ts new file mode 100644 index 0000000..eb3a618 --- /dev/null +++ b/__mocks__/order-response.mock.ts @@ -0,0 +1,31 @@ +export const mockOrdersResponse = { + data: [ + { + name: 'ORD-001', + date_order: '2024-01-01', + order_lines: [ + { + id: 1, + name: 'Not Invoiced', + qty_invoiced: 0, + qty_to_invoice: 1, + invoice_lines: [], + }, + { + id: 2, + name: 'Partially Invoiced', + qty_invoiced: 1, + qty_to_invoice: 1, + invoice_lines: [], + }, + { + id: 3, + name: 'Fully Invoiced', + qty_invoiced: 1, + qty_to_invoice: 0, + invoice_lines: [], + }, + ], + }, + ], +}; diff --git a/src/components/billing-status-summary.component.tsx b/src/components/billing-status-summary.component.tsx index b049d4f..ac3d86e 100644 --- a/src/components/billing-status-summary.component.tsx +++ b/src/components/billing-status-summary.component.tsx @@ -39,7 +39,7 @@ const PatientBillingStatusSummary: React.FC = const displayText = t('billingDetails', 'Billing Details'); const layout = useLayoutType(); const isTablet = layout === 'tablet'; - const isDesktop = layout === 'small-desktop' || layout === 'large-desktop'; + const isDesktop = !isTablet; const { groupedLines, isLoading, isValidating, error } = useBillingStatus(patient.id); diff --git a/src/components/billing-status-summary.test.tsx b/src/components/billing-status-summary.test.tsx index 7108bea..d5fb312 100644 --- a/src/components/billing-status-summary.test.tsx +++ b/src/components/billing-status-summary.test.tsx @@ -10,6 +10,10 @@ import { mockGroupedLines } from '../../__mocks__'; const mockedUseConfig = jest.mocked(useConfig); const mockedUseBillingStatus = jest.mocked(useBillingStatus); +jest.mock('../resources/billing-status.resource', () => ({ + useBillingStatus: jest.fn(), +})); + describe('PatientBillingStatusSummary', () => { beforeEach(() => { mockedUseConfig.mockReturnValue({ diff --git a/src/resources/billing-status-summary.test.ts b/src/resources/billing-status-summary.test.ts new file mode 100644 index 0000000..f1854c6 --- /dev/null +++ b/src/resources/billing-status-summary.test.ts @@ -0,0 +1,454 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { + type FetchResponse, + formatDate, + getDefaultsFromConfigSchema, + openmrsFetch, + restBaseUrl, + useConfig, +} from '@openmrs/esm-framework'; +import { BillingCondition, type Config, configSchema } from '../config-schema'; +import { + groupLinesByDay, + isLineApproved, + processBillingLines, + setVisitToLines, + shouldRetireLine, + useBillingStatus, + useInvoices, + useOrders, +} from './billing-status.resource'; +import { mockInvoicesResponse, mockOrdersResponse } from '../../__mocks__'; +import { type ErpInvoice, type ErpOrder } from '../types'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); +const mockUseConfig = jest.mocked(useConfig); +const mockFormatDate = jest.mocked(formatDate); +const mockRestBaseUrl = jest.mocked(restBaseUrl); + +const mockConfig = getDefaultsFromConfigSchema(configSchema) as Config; + +describe('useOrders', () => { + beforeEach(() => { + mockOpenmrsFetch.mockReset(); + mockUseConfig.mockReturnValue(mockConfig); + }); + + it('should handle various invoice quantities correctly', async () => { + mockOpenmrsFetch.mockResolvedValueOnce(mockOrdersResponse as FetchResponse); + const { result } = renderHook(() => useOrders('patient-1', mockConfig)); + + await waitFor(() => { + expect(result.current.orders[0].order_lines).toHaveLength(3); + }); + }); + + it('should handle network timeouts', async () => { + mockOpenmrsFetch.mockRejectedValueOnce({ message: 'Internal server error', status: 500 }); + + const { result } = renderHook(() => useOrders('patient-2', mockConfig)); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + expect(result.current.error.message).toBe('Internal server error'); + }); + }); +}); + +describe('useInvoices', () => { + beforeEach(() => { + mockOpenmrsFetch.mockReset(); + mockUseConfig.mockReturnValue(mockConfig); + }); + + it('should handle various payment states', async () => { + mockOpenmrsFetch.mockResolvedValueOnce(mockInvoicesResponse as FetchResponse); + const { result } = renderHook(() => useInvoices('patient-1', mockConfig)); + + await waitFor(() => { + expect(result.current.invoices).toHaveLength(3); + }); + }); + + it('should handle overdue and non-overdue invoices', async () => { + const pastDueDate = new Date(); + pastDueDate.setDate(pastDueDate.getDate() - 1); + + const futureDueDate = new Date(); + futureDueDate.setDate(futureDueDate.getDate() + 1); + + const response = { + data: [ + { + name: 'INV-001', + date: '2024-01-01', + payment_state: 'unpaid', + date_due: pastDueDate.toISOString(), + invoice_lines: [{ id: 1 }], + }, + { + name: 'INV-002', + date: '2024-01-01', + payment_state: 'unpaid', + date_due: futureDueDate.toISOString(), + invoice_lines: [{ id: 2 }], + }, + ], + }; + + mockOpenmrsFetch.mockResolvedValueOnce(response as FetchResponse); + const { result } = renderHook(() => useInvoices('patient-2', mockConfig)); + + await waitFor(() => { + expect(result.current.invoices).toHaveLength(2); + }); + }); +}); + +describe('useBillingStatus', () => { + const mockData = { + orders: [ + { + name: 'ORD-001', + date_order: '2024-01-01', + order_lines: [ + { + id: 1, + name: 'Product 1', + qty_invoiced: 1, + qty_to_invoice: 0, + invoice_lines: [1], + product_id: [1, 'Product 1'], + }, + ], + }, + ], + invoices: [ + { + name: 'INV-001', + date: '2024-01-01', + payment_state: 'paid', + date_due: '2024-02-01', + invoice_lines: [ + { + id: 1, + name: 'Invoice Line 1', + product_id: [1, 'Product 1'], + }, + ], + }, + ], + visits: [ + { + uuid: 'visit-1', + order: 'ORD-001', + startDate: '2024-01-01', + endDate: '2024-01-02', + }, + ], + }; + + beforeEach(() => { + mockOpenmrsFetch.mockReset(); + mockUseConfig.mockReturnValue(mockConfig); + }); + + it('should handle concurrent requests', async () => { + mockOpenmrsFetch + .mockResolvedValueOnce(mockData.orders as unknown as FetchResponse) + .mockResolvedValueOnce(mockData.invoices as unknown as FetchResponse); + + const { result } = renderHook(() => useBillingStatus('patient-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(Object.keys(result.current.groupedLines)).toBeTruthy(); + }); + }); +}); + +describe('Helper Functions', () => { + describe('isLineApproved', () => { + const testCases = [ + { + tags: [BillingCondition.PAID, BillingCondition.INVOICED], + expected: true, + description: 'should approve fully invoiced and paid orders', + }, + { + tags: [BillingCondition.ORDER, BillingCondition.NOT_INVOICED], + expected: false, + description: 'should not approve non-invoiced orders', + }, + ]; + + testCases.forEach(({ tags, expected, description }) => { + it(description, () => { + expect(isLineApproved(tags, mockConfig)).toBe(expected); + }); + }); + }); + + describe('shouldRetireLine', () => { + const testCases = [ + { + tags: [BillingCondition.ORDER, BillingCondition.FULLY_INVOICED], + expected: true, + description: 'should retire fully invoiced orders', + }, + { + tags: [BillingCondition.ORDER, BillingCondition.NOT_INVOICED], + expected: false, + description: 'should not retire non-invoiced orders', + }, + ]; + + testCases.forEach(({ tags, expected, description }) => { + it(description, () => { + expect(shouldRetireLine(tags, mockConfig)).toBe(expected); + }); + }); + }); + + describe('groupLinesByDay', () => { + it('should handle multiple lines on the same day', () => { + const lines = [ + { + id: 1, + date: '2024-01-01T10:00:00', + document: 'DOC-1', + order: 'ORD-1', + tags: [BillingCondition.ORDER], + displayName: 'Product 1', + approved: true, + visit: { uuid: 'visit-1', order: 'order-1', startDate: '2024-01-01', endDate: '2024-01-02' }, + }, + { + id: 2, + date: '2024-01-01T14:00:00', + document: 'DOC-2', + order: 'ORD-2', + tags: [BillingCondition.ORDER], + displayName: 'Product 2', + approved: false, + visit: { uuid: 'visit-1', order: 'order-2', startDate: '2024-01-01', endDate: '2024-01-02' }, + }, + ]; + + const result = groupLinesByDay(lines); + expect(result['2024-01-01'].lines).toHaveLength(2); + expect(result['2024-01-01'].status).toBe(false); // One line is not approved + }); + + it('should handle lines spanning multiple days', () => { + const lines = [ + { + id: 1, + date: '2024-01-01', + document: 'DOC-1', + order: 'ORD-1', + tags: [BillingCondition.ORDER], + displayName: 'Product 1', + approved: true, + visit: { uuid: 'visit-1', order: 'order-1', startDate: '2024-01-01', endDate: '2024-01-02' }, + }, + { + id: 2, + date: '2024-01-02', + document: 'DOC-2', + order: 'ORD-2', + tags: [BillingCondition.ORDER], + displayName: 'Product 2', + approved: true, + visit: { uuid: 'visit-1', order: 'order-2', startDate: '2024-01-01', endDate: '2024-01-02' }, + }, + ]; + + const result = groupLinesByDay(lines); + expect(Object.keys(result)).toHaveLength(2); + expect(result['2024-01-01'].status).toBe(true); + expect(result['2024-01-02'].status).toBe(true); + }); + }); + + describe('setVisitToLines', () => { + it('should correctly match visits to lines', () => { + const lines = [ + { + id: 1, + date: '2024-01-01', + document: 'DOC-1', + order: 'order-1', + tags: [BillingCondition.ORDER], + displayName: 'Product 1', + approved: true, + }, + ]; + + const visits = [ + { + uuid: 'visit-1', + order: 'order-1', + startDate: '2024-01-01', + endDate: '2024-01-02', + }, + ]; + + const result = setVisitToLines(lines, visits); + expect(result[0].visit).toBeDefined(); + expect(result[0].visit.uuid).toBe('visit-1'); + }); + + it('should handle lines without matching visits', () => { + const lines = [ + { + id: 1, + date: '2024-01-01', + document: 'DOC-1', + order: 'non-existing-order', + tags: [BillingCondition.ORDER], + displayName: 'Product 1', + approved: true, + }, + ]; + + const visits = [ + { + uuid: 'visit-1', + order: 'order-1', + startDate: '2024-01-01', + endDate: '2024-01-02', + }, + ]; + + const result = setVisitToLines(lines, visits); + expect(result[0].visit).toBeUndefined(); + }); + }); + + describe('processBillingLines', () => { + it('should process orders and invoices correctly', () => { + const orders = [ + { + name: 'ORD-001', + date_order: '2024-01-01', + order_lines: [ + { + id: 1, + name: 'Product 1', + qty_invoiced: 0, + qty_to_invoice: 1, + invoice_lines: [], + product_id: [1, 'Product 1'], + }, + ], + }, + ] as unknown as ErpOrder[]; + + const invoices = [ + { + name: 'INV-001', + date: '2024-01-01', + payment_state: 'paid', + date_due: '2024-02-01', + invoice_lines: [ + { + id: 1, + name: 'Invoice Line 1', + product_id: [1, 'Product 1'], + }, + ], + }, + ] as unknown as ErpInvoice[]; + + const result = processBillingLines(orders, invoices, mockConfig); + expect(result).toBeInstanceOf(Array); + expect(result.some((line) => line.tags.includes(BillingCondition.NOT_INVOICED))).toBe(true); + }); + + it('should handle empty orders and invoices', () => { + const result = processBillingLines([], [], mockConfig); + expect(result).toHaveLength(0); + }); + + it('should correctly tag lines based on payment state', () => { + const orders = [ + { + name: 'ORD-001', + date_order: '2024-01-01', + order_lines: [ + { + id: 1, + name: 'Product 1', + qty_invoiced: 1, + qty_to_invoice: 0, + invoice_lines: [1], + product_id: [1, 'Product 1'], + }, + ], + }, + ] as unknown as ErpOrder[]; + + const invoices = [ + { + name: 'INV-001', + date: '2024-01-01', + payment_state: 'paid', + date_due: '2024-02-01', + invoice_lines: [ + { + id: 1, + name: 'Invoice Line 1', + product_id: [1, 'Product 1'], + }, + ], + }, + ] as unknown as ErpInvoice[]; + + const result = processBillingLines(orders, invoices, mockConfig); + const invoiceLine = result.find((line) => line.tags.includes(BillingCondition.INVOICED)); + expect(invoiceLine.tags).toContain(BillingCondition.PAID); + }); + + it('should handle overdue status correctly', () => { + const pastDueDate = new Date(); + pastDueDate.setDate(pastDueDate.getDate() - 1); + + const orders = [ + { + name: 'ORD-001', + date_order: '2024-01-01', + order_lines: [ + { + name: 'Product 1', + qty_invoiced: 1, + qty_to_invoice: 0, + invoice_lines: [1], + product_id: [1, 'Product 1'], + }, + ], + }, + ] as ErpOrder[]; + + const invoices = [ + { + name: 'INV-001', + date: '2024-01-01', + payment_state: 'unpaid', + invoice_date_due: pastDueDate.toISOString(), + invoice_lines: [ + { + id: 1, + name: 'Invoice Line 1', + product_id: [1, 'Product 1'], + }, + ], + }, + ] as unknown as ErpInvoice[]; + + const result = processBillingLines(orders, invoices, mockConfig); + const invoiceLine = result.find((line) => line.tags.includes(BillingCondition.INVOICED)); + expect(invoiceLine.tags).toContain(BillingCondition.OVERDUE); + }); + }); +}); diff --git a/src/resources/billing-status.resource.ts b/src/resources/billing-status.resource.ts index 1533659..ddd69d1 100644 --- a/src/resources/billing-status.resource.ts +++ b/src/resources/billing-status.resource.ts @@ -109,7 +109,7 @@ export const useInvoices = (patientUuid: string, config: Config) => { }; }; -const processBillingLines = (orders: ErpOrder[], invoices: ErpInvoice[], config: Config): BillingLine[] => { +export const processBillingLines = (orders: ErpOrder[], invoices: ErpInvoice[], config: Config): BillingLine[] => { const lines: BillingLine[] = []; // Process order lines @@ -150,7 +150,7 @@ const processBillingLines = (orders: ErpOrder[], invoices: ErpInvoice[], config: tags.push(BillingCondition.NOT_PAID); } - if (new Date(invoice.date_due) >= new Date()) { + if (new Date(invoice.invoice_date_due) <= new Date()) { tags.push(BillingCondition.OVERDUE); } else { tags.push(BillingCondition.NOT_OVERDUE); @@ -193,7 +193,7 @@ const processBillingLines = (orders: ErpOrder[], invoices: ErpInvoice[], config: .filter((line) => !line.retire); }; -const setVisitToLines = (lines: BillingLine[], visits: BillingVisit[]): BillingLine[] => { +export const setVisitToLines = (lines: BillingLine[], visits: BillingVisit[]): BillingLine[] => { return lines.map((line) => { // TODO this matching needs the external_id present on erp order to match the exact visit encounter order const matchingVisit = visits.find((visit) => line.order === visit.order); @@ -207,7 +207,7 @@ const setVisitToLines = (lines: BillingLine[], visits: BillingVisit[]): BillingL }); }; -const isLineApproved = (tags: string[], config: Config): boolean => { +export const isLineApproved = (tags: string[], config: Config): boolean => { return ( config.approvedConditions.some( (condition) => @@ -218,7 +218,7 @@ const isLineApproved = (tags: string[], config: Config): boolean => { .sort(), ) === JSON.stringify(tags.sort()), ) || - config.nonApprovedConditions.some( + !config.nonApprovedConditions.some( (condition) => JSON.stringify( condition @@ -230,7 +230,7 @@ const isLineApproved = (tags: string[], config: Config): boolean => { ); }; -const shouldRetireLine = (tags: string[], config: Config): boolean => { +export const shouldRetireLine = (tags: string[], config: Config): boolean => { return config.retireLinesConditions.some( (condition) => JSON.stringify( @@ -242,7 +242,7 @@ const shouldRetireLine = (tags: string[], config: Config): boolean => { ); }; -const groupByVisits = (lines: BillingLine[]): GroupedBillingLines => { +export const groupByVisits = (lines: BillingLine[]): GroupedBillingLines => { const groupedLines: GroupedBillingLines = {}; lines.forEach((line) => { @@ -263,7 +263,7 @@ const groupByVisits = (lines: BillingLine[]): GroupedBillingLines => { return groupedLines; }; -const groupLinesByDay = (linesToGroup: BillingLine[]): GroupedBillingLines => { +export const groupLinesByDay = (linesToGroup: BillingLine[]): GroupedBillingLines => { const groupedLines: GroupedBillingLines = {}; linesToGroup.forEach((line) => {