diff --git a/src/components/app/IEDeprecationDialog-test.tsx b/src/components/app/IEDeprecationDialog-test.tsx new file mode 100644 index 0000000000..6019fb891c --- /dev/null +++ b/src/components/app/IEDeprecationDialog-test.tsx @@ -0,0 +1,120 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import userEvent from '@testing-library/user-event' +import { initialState } from 'src/services/mockedData' +import { PageviewInfo } from 'src/const/Analytics' +import type { RootState } from 'src/redux/Store' + +describe('IEDeprecationDialog', () => { + const mockOnAnalyticPageview = vi.fn() + + const defaultProps = { + onAnalyticPageview: mockOnAnalyticPageview, + } + + const preloadedState = { + ...initialState, + profiles: { + ...initialState.profiles, + widgetProfile: { + enable_ie_11_deprecation: true, + }, + }, + } as unknown as Partial + + const setUserAgent = (userAgent: string) => { + Object.defineProperty(window.navigator, 'userAgent', { + value: userAgent, + configurable: true, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetModules() + }) + + it('renders dialog when using IE with feature flag enabled', async () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko') + vi.resetModules() + + const { IEDeprecationDialog } = await import('src/components/app/IEDeprecationDialog') + + render(, { preloadedState }) + + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + expect(mockOnAnalyticPageview).toHaveBeenCalledWith(PageviewInfo.CONNECT_IE_11_DEPRECATION[1]) + }) + + it('does not render when not using IE', async () => { + setUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ) + vi.resetModules() + + const { IEDeprecationDialog } = await import('src/components/app/IEDeprecationDialog') + + render(, { preloadedState }) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + expect(mockOnAnalyticPageview).not.toHaveBeenCalled() + }) + + it('does not render when feature flag is disabled', async () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko') + vi.resetModules() + + const { IEDeprecationDialog } = await import('src/components/app/IEDeprecationDialog') + + const stateWithoutFlag = { + ...initialState, + profiles: { + ...initialState.profiles, + widgetProfile: { + enable_ie_11_deprecation: false, + }, + }, + } as unknown as Partial + + render(, { preloadedState: stateWithoutFlag }) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + expect(mockOnAnalyticPageview).not.toHaveBeenCalled() + }) + + it('hides dialog when close button is clicked', async () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko') + vi.resetModules() + + const { IEDeprecationDialog } = await import('src/components/app/IEDeprecationDialog') + const user = userEvent.setup() + + render(, { preloadedState }) + + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /close modal/i })) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + }) + + it('hides dialog when continue button is clicked', async () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko') + vi.resetModules() + + const { IEDeprecationDialog } = await import('src/components/app/IEDeprecationDialog') + const user = userEvent.setup() + + render(, { preloadedState }) + + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /continue/i })) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + }) +}) diff --git a/src/const/Accounts-test.tsx b/src/const/Accounts-test.tsx new file mode 100644 index 0000000000..5f4d1ffeb9 --- /dev/null +++ b/src/const/Accounts-test.tsx @@ -0,0 +1,162 @@ +import { AccountTypeNames, ReadableAccountTypes } from 'src/const/Accounts' + +describe('Accounts Constants', () => { + describe('ReadableAccountTypes', () => { + it('should have UNKNOWN as 0', () => { + expect(ReadableAccountTypes.UNKNOWN).toBe(0) + }) + + it('should have CHECKING as 1', () => { + expect(ReadableAccountTypes.CHECKING).toBe(1) + }) + + it('should have SAVINGS as 2', () => { + expect(ReadableAccountTypes.SAVINGS).toBe(2) + }) + + it('should have LOAN as 3', () => { + expect(ReadableAccountTypes.LOAN).toBe(3) + }) + + it('should have CREDIT_CARD as 4', () => { + expect(ReadableAccountTypes.CREDIT_CARD).toBe(4) + }) + + it('should have INVESTMENT as 5', () => { + expect(ReadableAccountTypes.INVESTMENT).toBe(5) + }) + + it('should have LINE_OF_CREDIT as 6', () => { + expect(ReadableAccountTypes.LINE_OF_CREDIT).toBe(6) + }) + + it('should have MORTGAGE as 7', () => { + expect(ReadableAccountTypes.MORTGAGE).toBe(7) + }) + + it('should have PROPERTY as 8', () => { + expect(ReadableAccountTypes.PROPERTY).toBe(8) + }) + + it('should have CASH as 9', () => { + expect(ReadableAccountTypes.CASH).toBe(9) + }) + + it('should have INSURANCE as 10', () => { + expect(ReadableAccountTypes.INSURANCE).toBe(10) + }) + + it('should have PREPAID as 11', () => { + expect(ReadableAccountTypes.PREPAID).toBe(11) + }) + + it('should have CHECKING_LINE_OF_CREDIT as 12', () => { + expect(ReadableAccountTypes.CHECKING_LINE_OF_CREDIT).toBe(12) + }) + + it('should have exactly 13 account types', () => { + expect(Object.keys(ReadableAccountTypes)).toHaveLength(13) + }) + + it('should have all numeric values', () => { + Object.values(ReadableAccountTypes).forEach((value) => { + expect(typeof value).toBe('number') + }) + }) + + it('should have unique values', () => { + const values = Object.values(ReadableAccountTypes) + const uniqueValues = new Set(values) + expect(uniqueValues.size).toBe(values.length) + }) + }) + + describe('AccountTypeNames', () => { + it('should have 13 account type names', () => { + expect(AccountTypeNames).toHaveLength(13) + }) + + it('should have "Other" at index 0 for UNKNOWN', () => { + expect(AccountTypeNames[ReadableAccountTypes.UNKNOWN]).toBe('Other') + }) + + it('should have "Checking" at index 1 for CHECKING', () => { + expect(AccountTypeNames[ReadableAccountTypes.CHECKING]).toBe('Checking') + }) + + it('should have "Savings" at index 2 for SAVINGS', () => { + expect(AccountTypeNames[ReadableAccountTypes.SAVINGS]).toBe('Savings') + }) + + it('should have "Loan" at index 3 for LOAN', () => { + expect(AccountTypeNames[ReadableAccountTypes.LOAN]).toBe('Loan') + }) + + it('should have "Credit Card" at index 4 for CREDIT_CARD', () => { + expect(AccountTypeNames[ReadableAccountTypes.CREDIT_CARD]).toBe('Credit Card') + }) + + it('should have "Investment" at index 5 for INVESTMENT', () => { + expect(AccountTypeNames[ReadableAccountTypes.INVESTMENT]).toBe('Investment') + }) + + it('should have "Line of Credit" at index 6 for LINE_OF_CREDIT', () => { + expect(AccountTypeNames[ReadableAccountTypes.LINE_OF_CREDIT]).toBe('Line of Credit') + }) + + it('should have "Mortgage" at index 7 for MORTGAGE', () => { + expect(AccountTypeNames[ReadableAccountTypes.MORTGAGE]).toBe('Mortgage') + }) + + it('should have "Property" at index 8 for PROPERTY', () => { + expect(AccountTypeNames[ReadableAccountTypes.PROPERTY]).toBe('Property') + }) + + it('should have "Cash" at index 9 for CASH', () => { + expect(AccountTypeNames[ReadableAccountTypes.CASH]).toBe('Cash') + }) + + it('should have "Insurance" at index 10 for INSURANCE', () => { + expect(AccountTypeNames[ReadableAccountTypes.INSURANCE]).toBe('Insurance') + }) + + it('should have "Prepaid" at index 11 for PREPAID', () => { + expect(AccountTypeNames[ReadableAccountTypes.PREPAID]).toBe('Prepaid') + }) + + it('should have "Checking" at index 12 for CHECKING_LINE_OF_CREDIT', () => { + expect(AccountTypeNames[ReadableAccountTypes.CHECKING_LINE_OF_CREDIT]).toBe('Checking') + }) + + it('should have all string values', () => { + AccountTypeNames.forEach((name) => { + expect(typeof name).toBe('string') + }) + }) + }) + + describe('Integration between ReadableAccountTypes and AccountTypeNames', () => { + it('should map all ReadableAccountTypes to valid AccountTypeNames', () => { + Object.entries(ReadableAccountTypes).forEach(([_key, value]) => { + expect(AccountTypeNames[value]).toBeDefined() + expect(typeof AccountTypeNames[value]).toBe('string') + }) + }) + + it('should have correct mapping for UNKNOWN type', () => { + const name = AccountTypeNames[ReadableAccountTypes.UNKNOWN] + expect(name).toBe('Other') + }) + + it('should have correct mapping for standard account types', () => { + expect(AccountTypeNames[ReadableAccountTypes.CHECKING]).toBe('Checking') + expect(AccountTypeNames[ReadableAccountTypes.SAVINGS]).toBe('Savings') + expect(AccountTypeNames[ReadableAccountTypes.CREDIT_CARD]).toBe('Credit Card') + }) + + it('should handle CHECKING_LINE_OF_CREDIT as Checking', () => { + const name = AccountTypeNames[ReadableAccountTypes.CHECKING_LINE_OF_CREDIT] + expect(name).toBe('Checking') + }) + }) +}) diff --git a/src/const/jobDetailCode-test.tsx b/src/const/jobDetailCode-test.tsx new file mode 100644 index 0000000000..e1f51c6943 --- /dev/null +++ b/src/const/jobDetailCode-test.tsx @@ -0,0 +1,51 @@ +import { JOB_DETAIL_CODE } from 'src/const/jobDetailCode' + +describe('JOB_DETAIL_CODE Constants', () => { + describe('Structure', () => { + it('should be an object', () => { + expect(typeof JOB_DETAIL_CODE).toBe('object') + expect(JOB_DETAIL_CODE).not.toBeNull() + }) + + it('should have exactly 1 property', () => { + expect(Object.keys(JOB_DETAIL_CODE)).toHaveLength(1) + }) + + it('should have all numeric values', () => { + Object.values(JOB_DETAIL_CODE).forEach((value) => { + expect(typeof value).toBe('number') + }) + }) + + it('should have unique values', () => { + const values = Object.values(JOB_DETAIL_CODE) + const uniqueValues = new Set(values) + expect(uniqueValues.size).toBe(values.length) + }) + }) + + describe('NO_VERIFIABLE_ACCOUNTS', () => { + it('should exist', () => { + expect(JOB_DETAIL_CODE.NO_VERIFIABLE_ACCOUNTS).toBeDefined() + }) + + it('should equal 1000', () => { + expect(JOB_DETAIL_CODE.NO_VERIFIABLE_ACCOUNTS).toBe(1000) + }) + + it('should be a number', () => { + expect(typeof JOB_DETAIL_CODE.NO_VERIFIABLE_ACCOUNTS).toBe('number') + }) + }) + + describe('Export', () => { + it('should export JOB_DETAIL_CODE as a named export', () => { + expect(JOB_DETAIL_CODE).toBeDefined() + }) + + it('should not be frozen or sealed', () => { + expect(Object.isFrozen(JOB_DETAIL_CODE)).toBe(false) + expect(Object.isSealed(JOB_DETAIL_CODE)).toBe(false) + }) + }) +}) diff --git a/src/context/ApiContext-test.tsx b/src/context/ApiContext-test.tsx new file mode 100644 index 0000000000..06a4dba027 --- /dev/null +++ b/src/context/ApiContext-test.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { initialState } from 'src/services/mockedData' +import { ApiProvider, useApi } from 'src/context/ApiContext' +import { CreateMemberForm } from 'src/views/credentials/CreateMemberForm' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' + +describe('ApiContext', () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + current_institution_guid: 'INS-123', + selectedInstitution: { + guid: 'INS-123', + code: 'mxbank', + name: 'MX Bank', + }, + institutions: [ + { + guid: 'INS-123', + code: 'mxbank', + name: 'MX Bank', + }, + ], + }, + } + + const defaultProps = { + onError: () => {}, + onSuccess: () => {}, + } + + it('provides API to child components', async () => { + const mockGetInstitutionCredentials = vi.fn().mockResolvedValue([ + { + guid: 'CRD-1', + label: 'Username', + field_name: 'username', + field_type: 'TEXT', + }, + ]) + + render( + + + , + { preloadedState }, + ) + + await waitFor(() => { + expect(mockGetInstitutionCredentials).toHaveBeenCalledWith('INS-123') + }) + + expect(screen.getByText('Username')).toBeInTheDocument() + }) + + it('allows custom API values to be provided', async () => { + const customGetInstitutionCredentials = vi.fn().mockResolvedValue([ + { + guid: 'CRD-2', + label: 'Password', + field_name: 'password', + field_type: 'PASSWORD', + }, + ]) + + render( + + + , + { preloadedState }, + ) + + await waitFor(() => { + expect(customGetInstitutionCredentials).toHaveBeenCalledWith('INS-123') + }) + + expect(screen.getByText('Password')).toBeInTheDocument() + }) + + it('provides default API values when used outside provider', () => { + const TestComponent = () => { + const { api } = useApi() + return ( +
+
{typeof api.loadMembers === 'function' ? 'yes' : 'no'}
+
+ ) + } + + render(, { preloadedState }) + + expect(screen.getByTestId('has-api')).toHaveTextContent('yes') + }) +}) diff --git a/src/context/ApiContext.tsx b/src/context/ApiContext.tsx index 0be06eccb8..540f8affc0 100644 --- a/src/context/ApiContext.tsx +++ b/src/context/ApiContext.tsx @@ -141,9 +141,6 @@ const ApiProvider = ({ apiValue, children }: ApiProviderTypes) => { const useApi = () => { const context = React.useContext(ApiContext) - if (context === undefined) { - throw new Error('useApi must be used within a ApiProvider') - } return { api: context } } diff --git a/src/context/WebSocketContext-test.tsx b/src/context/WebSocketContext-test.tsx new file mode 100644 index 0000000000..79c791d5fc --- /dev/null +++ b/src/context/WebSocketContext-test.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { of } from 'rxjs' +import { WebSocketProvider, useWebSocket, WebSocketConnection } from 'src/context/WebSocketContext' + +describe('WebSocketContext', () => { + it('should return undefined when no WebSocket connection is provided', () => { + const { result } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current).toBeUndefined() + }) + + it('should return the WebSocket connection when provided', () => { + const mockConnection: WebSocketConnection = { + isConnected: () => true, + webSocketMessages$: of({ type: 'test' }), + } + + const { result } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current).toBe(mockConnection) + expect(result.current?.isConnected()).toBe(true) + }) + + it('should allow accessing webSocketMessages$ observable', () => { + const mockConnection: WebSocketConnection = { + isConnected: () => false, + webSocketMessages$: of({ event: 'test', payload: { id: 123 } }), + } + + const { result } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current?.webSocketMessages$).toBeDefined() + + let receivedMessage: unknown + result.current?.webSocketMessages$.subscribe((msg) => { + receivedMessage = msg + }) + + expect(receivedMessage).toEqual({ event: 'test', payload: { id: 123 } }) + }) + + it('should provide the same connection to multiple consumers', () => { + const mockConnection: WebSocketConnection = { + isConnected: vi.fn(() => true), + webSocketMessages$: of({}), + } + + const { result: result1 } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + const { result: result2 } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result1.current).toBe(mockConnection) + expect(result2.current).toBe(mockConnection) + }) +}) diff --git a/src/privacy/withProtection-test.tsx b/src/privacy/withProtection-test.tsx new file mode 100644 index 0000000000..9dd2963d46 --- /dev/null +++ b/src/privacy/withProtection-test.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { maskInputFn, withProtection } from 'src/privacy/withProtection' +import { render } from 'src/utilities/testingLibrary' + +describe('maskInputFn', () => { + it('should mask input text by default', () => { + const result = maskInputFn('password123') + expect(result).toBe('***********') + }) + + it('should return original text when element has data-ph-unmask="true"', () => { + const element = document.createElement('input') + element.setAttribute('data-ph-unmask', 'true') + const result = maskInputFn('plainText123', element) + expect(result).toBe('plainText123') + }) +}) + +describe('withProtection', () => { + it('should wrap component with ph-no-capture class by default', () => { + const TestComponent = ({ 'data-test': dataTest }: { 'data-test': string }) => ( +
Sensitive Content
+ ) + const ProtectedComponent = withProtection(TestComponent) + + render() + + const wrapper = document.querySelector('.ph-no-capture') + expect(wrapper).toBeTruthy() + expect(screen.getByTestId('test-component')).toHaveTextContent('Sensitive Content') + }) + + it('should not wrap component when allowCapture is true', () => { + const TestComponent = ({ 'data-test': dataTest }: { 'data-test': string }) => ( +
Public Content
+ ) + const ProtectedComponent = withProtection(TestComponent) + + render() + + const wrapper = document.querySelector('.ph-no-capture') + expect(wrapper).toBeNull() + expect(screen.getByTestId('test-component')).toHaveTextContent('Public Content') + }) + + it('should add data-ph-unmask attribute when allowCapture is true', () => { + const TestComponent = React.forwardRef< + HTMLInputElement, + { 'data-test': string; 'data-ph-unmask'?: boolean } + >((props, ref) => ) + TestComponent.displayName = 'TestComponent' + + const ProtectedComponent = withProtection(TestComponent) + + render() + + const input = screen.getByTestId('test-input') + expect(input.getAttribute('data-ph-unmask')).toBe('true') + }) +})