diff --git a/src/Connect-test.tsx b/src/Connect.test.tsx similarity index 61% rename from src/Connect-test.tsx rename to src/Connect.test.tsx index bb736254e2..9060e22868 100644 --- a/src/Connect-test.tsx +++ b/src/Connect.test.tsx @@ -2,8 +2,10 @@ import React from 'react' import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest' import { render, screen, waitFor } from 'src/utilities/testingLibrary' import { Connect } from './Connect' -import { initialState, masterData } from 'src/services/mockedData' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { initialState, masterData, institutionData } from 'src/services/mockedData' import { STEPS } from 'src/const/Connect' +import { createRenderConnectStepInitialState } from 'src/utilities/test/createRenderConnectStepInitialState' describe('', () => { const mockPostMessage = vi.fn() @@ -261,4 +263,114 @@ describe('', () => { }) }) }) + + describe('Connect - Demo Connect Guard', () => { + const defaultProps = { + clientConfig: { current_institution_guid: 'INS-123' } as ClientConfigType, + onShowConnectSuccessSurvey: () => undefined, + onSubmitConnectSuccessSurvey: () => {}, + profiles: { ...masterData, loading: false }, + } + + const nonDemoInstitution = { ...institutionData.institution, is_demo: false } + const demoInstitution = { ...institutionData.institution, is_demo: true } + const demoUser = { ...masterData.user, is_demo: true } + const regularUser = { ...masterData.user, is_demo: false } + + it('blocks demo user from accessing non-demo institution', async () => { + const mockApiValue = { + ...apiValueMock, + loadInstitutionByGuid: vi.fn().mockResolvedValue(nonDemoInstitution), + loadMembers: vi.fn().mockResolvedValue([]), + } + + render( + , + { apiValue: mockApiValue }, + ) + + expect(await screen.findByText(/Demo mode active/i)).toBeInTheDocument() + }) + + it('allows demo user to access demo institution', async () => { + const mockApiValue = { + ...apiValueMock, + loadInstitutionByGuid: vi.fn().mockResolvedValue(demoInstitution), + loadMembers: vi.fn().mockResolvedValue([]), + } + + render( + , + { apiValue: mockApiValue }, + ) + + expect(await screen.findByText(/Log in at Test Bank/i)).toBeInTheDocument() + expect(screen.queryByText(/Demo mode active/i)).not.toBeInTheDocument() + }) + + it('allows regular user to access non-demo institution', async () => { + const mockApiValue = { + ...apiValueMock, + loadInstitutionByGuid: vi.fn().mockResolvedValue(nonDemoInstitution), + loadMembers: vi.fn().mockResolvedValue([]), + } + + render( + , + { apiValue: mockApiValue }, + ) + + expect(await screen.findByText(/Log in at Test Bank/i)).toBeInTheDocument() + expect(screen.queryByText(/Demo mode active/i)).not.toBeInTheDocument() + }) + + describe('back button', () => { + const props = { + ...defaultProps, + clientConfig: {} as ClientConfigType, + profiles: { ...masterData, user: demoUser, loading: false }, + } + const guardState = (steps: string[]) => { + const base = createRenderConnectStepInitialState( + steps[steps.length - 1], + nonDemoInstitution, + ) + return { + ...base, + connect: { + ...base.connect, + isComponentLoading: false, + location: steps.map((s) => ({ step: s })), + }, + } + } + + it('is hidden when guard fires as the first screen', async () => { + render(, { preloadedState: guardState([STEPS.ENTER_CREDENTIALS]) }) + await waitFor(() => expect(screen.getByText(/Demo mode active/i)).toBeInTheDocument()) + expect(screen.queryByTestId('back-button')).not.toBeInTheDocument() + }) + + it('navigates back to search when the previous step is search', async () => { + const { user } = render(, { + preloadedState: guardState([STEPS.SEARCH, STEPS.ENTER_CREDENTIALS]), + }) + await waitFor(() => expect(screen.getByTestId('back-button')).toBeInTheDocument()) + await user.click(screen.getByTestId('back-button')) + expect(screen.queryByText('Select your institution')).toBeInTheDocument() + }) + + it('pops one step back rather than resetting to search', async () => { + const { user } = render(, { + preloadedState: guardState([STEPS.SEARCH, STEPS.CONSENT, STEPS.ENTER_CREDENTIALS]), + }) + await waitFor(() => expect(screen.getByTestId('back-button')).toBeInTheDocument()) + await user.click(screen.getByTestId('back-button')) + await waitFor(() => expect(screen.getByText(/Demo mode active/i)).toBeInTheDocument()) + }) + }) + }) }) diff --git a/src/components/RenderConnectStep-test.jsx b/src/components/RenderConnectStep-test.jsx index 34674b7c27..4aa9c65bc4 100644 --- a/src/components/RenderConnectStep-test.jsx +++ b/src/components/RenderConnectStep-test.jsx @@ -36,8 +36,15 @@ describe('RenderConnectStep', () => { }) describe('Step Rendering', () => { - it('should render DemoConnectGuard when step is DEMO_CONNECT_GUARD', () => { - const state = createRenderConnectStepInitialState(STEPS.DEMO_CONNECT_GUARD, mockInstitution) + it('should render DemoConnectGuard when user is demo and institution is not demo', () => { + const nonDemoInstitution = { ...mockInstitution, is_demo: false } + const state = { + ...createRenderConnectStepInitialState(STEPS.ENTER_CREDENTIALS, nonDemoInstitution), + profiles: { + ...initialState.profiles, + user: { ...initialState.profiles.user, is_demo: true }, + }, + } const { container } = render(, { preloadedState: state, @@ -56,10 +63,73 @@ describe('RenderConnectStep', () => { const errorIcon = container.querySelector('svg.MuiSvgIcon-colorError') expect(errorIcon).toBeInTheDocument() - const button = screen.getByRole('button', { name: /return to institution selection/i }) + const button = screen.getByRole('button', { name: /go back/i }) expect(button).toBeInTheDocument() }) + it('should render DemoConnectGuard on ACTIONABLE_ERROR when user is demo and institution is not demo', () => { + const nonDemoInstitution = { ...mockInstitution, is_demo: false } + const state = { + ...createRenderConnectStepInitialState(STEPS.ACTIONABLE_ERROR, nonDemoInstitution), + profiles: { + ...initialState.profiles, + user: { ...initialState.profiles.user, is_demo: true }, + }, + } + + render(, { preloadedState: state }) + + expect(screen.getByText('Demo mode active')).toBeInTheDocument() + }) + + it('should NOT render DemoConnectGuard on INSTITUTION_STATUS_DETAILS even when user is demo and institution is not demo', () => { + const nonDemoInstitution = { ...mockInstitution, is_demo: false } + const state = { + ...createRenderConnectStepInitialState( + STEPS.INSTITUTION_STATUS_DETAILS, + nonDemoInstitution, + ), + profiles: { + ...initialState.profiles, + user: { ...initialState.profiles.user, is_demo: true }, + }, + } + + render(, { preloadedState: state }) + + expect(screen.queryByText('Demo mode active')).not.toBeInTheDocument() + }) + + it('should NOT render DemoConnectGuard when user is demo but institution is also demo', () => { + const demoInstitution = { ...mockInstitution, is_demo: true } + const state = { + ...createRenderConnectStepInitialState(STEPS.ENTER_CREDENTIALS, demoInstitution), + profiles: { + ...initialState.profiles, + user: { ...initialState.profiles.user, is_demo: true }, + }, + } + + render(, { preloadedState: state }) + + expect(screen.queryByText('Demo mode active')).not.toBeInTheDocument() + }) + + it('should NOT render DemoConnectGuard when user is not demo', () => { + const nonDemoInstitution = { ...mockInstitution, is_demo: false } + const state = { + ...createRenderConnectStepInitialState(STEPS.ENTER_CREDENTIALS, nonDemoInstitution), + profiles: { + ...initialState.profiles, + user: { ...initialState.profiles.user, is_demo: false }, + }, + } + + render(, { preloadedState: state }) + + expect(screen.queryByText('Demo mode active')).not.toBeInTheDocument() + }) + it('should render Search view for SEARCH step', async () => { const state = createRenderConnectStepInitialState(STEPS.SEARCH) @@ -244,7 +314,7 @@ describe('RenderConnectStep', () => { }) it('should not apply maxHeight for non-SEARCH steps', () => { - const state = createRenderConnectStepInitialState(STEPS.DEMO_CONNECT_GUARD, mockInstitution) + const state = createRenderConnectStepInitialState(STEPS.CONNECTING, mockInstitution) const { container } = render(, { preloadedState: state, diff --git a/src/components/RenderConnectStep.js b/src/components/RenderConnectStep.js index 06569aba79..b67e9042a6 100644 --- a/src/components/RenderConnectStep.js +++ b/src/components/RenderConnectStep.js @@ -87,8 +87,29 @@ const RenderConnectStep = (props) => { const hasAtriumAPI = client.has_atrium_api const showSupport = widgetProfile.enable_support_requests && mode === AGG_MODE + const user = useSelector((state) => state.profiles.user) const isDeleteInstitutionOptionEnabled = widgetProfile?.display_delete_option_in_connect ?? true + const STEPS_TO_DEMO_GUARD = [ + STEPS.ADDITIONAL_PRODUCT, + STEPS.CONSENT, + STEPS.ENTER_CREDENTIALS, + STEPS.CONNECTING, + STEPS.MFA, + STEPS.CONNECTED, + STEPS.ACTIONABLE_ERROR, + STEPS.OAUTH_ERROR, + ] + const shouldShowDemoConnectGuard = + STEPS_TO_DEMO_GUARD.includes(step) && + user?.is_demo && + selectedInstitution?.guid && + !selectedInstitution?.is_demo + + if (shouldShowDemoConnectGuard) { + return + } + let connectStepView = null if (step === STEPS.DISCLOSURE) { @@ -111,8 +132,6 @@ const RenderConnectStep = (props) => { throw new Error('invalid product offer') connectStepView = - } else if (step === STEPS.DEMO_CONNECT_GUARD) { - connectStepView = } else if (step === STEPS.ADD_MANUAL_ACCOUNT) { connectStepView = ( { institutionStatusIsUnavailable(action.payload.institutionStatus)) ) { nextStep = STEPS.INSTITUTION_STATUS_DETAILS - } else if (action.payload.user?.is_demo && !action.payload.institution?.is_demo) { - nextStep = STEPS.DEMO_CONNECT_GUARD } else if (canOfferVerification || canOfferAggregation) { nextStep = STEPS.ADDITIONAL_PRODUCT } else if (action.payload.consentIsEnabled) { @@ -574,7 +572,6 @@ function getStartingStep( (institution && institutionIsBlockedForCostReasons(institution)) || (member && memberIsBlockedForCostReasons(member)) || !institutionIsAvailable - if (shouldStepToInstitutionStatusDetails) { return STEPS.INSTITUTION_STATUS_DETAILS } else if (shouldStepToMFA) diff --git a/src/redux/reducers/__tests__/Connect-test.js b/src/redux/reducers/__tests__/Connect-test.js index 3751806e11..c6c467a9ef 100644 --- a/src/redux/reducers/__tests__/Connect-test.js +++ b/src/redux/reducers/__tests__/Connect-test.js @@ -455,10 +455,7 @@ describe('Connect redux store', () => { const config = { mode: VERIFY_MODE } const afterState = reducer( { ...defaultState, isComponentLoading: true }, - { - type: ActionTypes.LOAD_CONNECT_SUCCESS, - payload: { config, members: [], widgetProfile }, - }, + loadConnectSuccess({ config, members: [], widgetProfile }), ) expect(afterState.location[afterState.location.length - 1].step).toEqual(STEPS.SEARCH) }) @@ -473,10 +470,7 @@ describe('Connect redux store', () => { const members = [member] const afterState = reducer( { ...defaultState, isComponentLoading: true }, - { - type: ActionTypes.LOAD_CONNECT_SUCCESS, - payload: { config, member, members, widgetProfile }, - }, + loadConnectSuccess({ config, member, members, widgetProfile }), ) expect(afterState.location[afterState.location.length - 1].step).toEqual( STEPS.ACTIONABLE_ERROR, @@ -500,10 +494,7 @@ describe('Connect redux store', () => { const members = [member] const afterState = reducer( { ...defaultState, isComponentLoading: true }, - { - type: ActionTypes.LOAD_CONNECT_SUCCESS, - payload: { config, member, members, widgetProfile }, - }, + loadConnectSuccess({ config, member, members, widgetProfile }), ) expect(afterState.location[afterState.location.length - 1].step).toEqual( STEPS.ACTIONABLE_ERROR, @@ -527,10 +518,7 @@ describe('Connect redux store', () => { const members = [member] const afterState = reducer( { ...defaultState, isComponentLoading: true }, - { - type: ActionTypes.LOAD_CONNECT_SUCCESS, - payload: { config, member, members, widgetProfile }, - }, + loadConnectSuccess({ config, member, members, widgetProfile }), ) expect(afterState.location[afterState.location.length - 1].step).toEqual( STEPS.ENTER_CREDENTIALS, @@ -554,10 +542,7 @@ describe('Connect redux store', () => { const members = [member] const afterState = reducer( { ...defaultState, isComponentLoading: true }, - { - type: ActionTypes.LOAD_CONNECT_SUCCESS, - payload: { config, member, members, widgetProfile }, - }, + loadConnectSuccess({ config, member, members, widgetProfile }), ) expect(afterState.location[afterState.location.length - 1].step).toEqual(STEPS.MFA) }) @@ -578,10 +563,7 @@ describe('Connect redux store', () => { const members = [member] const afterState = reducer( { ...defaultState, isComponentLoading: true }, - { - type: ActionTypes.LOAD_CONNECT_SUCCESS, - payload: { config, member, members, accounts: [], widgetProfile }, - }, + loadConnectSuccess({ config, member, members, accounts: [], widgetProfile }), ) expect(afterState.location[afterState.location.length - 1].step).toEqual( STEPS.ACTIONABLE_ERROR, @@ -641,19 +623,6 @@ describe('Connect redux store', () => { STEPS.INSTITUTION_STATUS_DETAILS, ) }) - - it('should set the step to DEMO_CONNECT_GUARD when the user is a demo user but the institution is not a demo institution', () => { - const institution = { guid: 'INST-1', is_demo: false, credentials } - const user = { guid: 'USR-1', is_demo: true } - const afterState = reducer(defaultState, { - type: ActionTypes.SELECT_INSTITUTION_SUCCESS, - payload: { institution, user }, - }) - - expect(afterState.location[afterState.location.length - 1].step).toEqual( - STEPS.DEMO_CONNECT_GUARD, - ) - }) }) describe('LOAD_CONNECT', () => { diff --git a/src/views/demoConnectGuard/DemoConnectGuard-test.tsx b/src/views/demoConnectGuard/DemoConnectGuard-test.tsx index d3f393ad37..3d8018096a 100644 --- a/src/views/demoConnectGuard/DemoConnectGuard-test.tsx +++ b/src/views/demoConnectGuard/DemoConnectGuard-test.tsx @@ -3,6 +3,7 @@ import { render, screen } from 'src/utilities/testingLibrary' import { DemoConnectGuard } from './DemoConnectGuard' import { initialState } from 'src/services/mockedData' import RenderConnectStep from 'src/components/RenderConnectStep' +import { PostMessageContext } from 'src/ConnectWidget' import { STEPS } from 'src/const/Connect' import { createRenderConnectStepInitialState } from 'src/utilities/test/createRenderConnectStepInitialState' @@ -15,6 +16,17 @@ describe('DemoConnectGuard', () => { url: 'https://testbank.com', } + const defaultProps = { + availableAccountTypes: [], + handleConsentGoBack: vi.fn(), + handleOAuthGoBack: vi.fn(), + handleCredentialsGoBack: vi.fn(), + navigationRef: vi.fn(), + onManualAccountAdded: vi.fn(), + onUpsertMember: vi.fn(), + setConnectLocalState: vi.fn(), + } + const mockInitialState = { ...initialState, connect: { @@ -38,42 +50,58 @@ describe('DemoConnectGuard', () => { const errorIcon = container.querySelector('svg.MuiSvgIcon-colorError') expect(errorIcon).toBeInTheDocument() - const button = screen.getByRole('button', { name: /return to institution selection/i }) + const button = screen.getByRole('button', { name: /go back/i }) expect(button).toBeInTheDocument() }) it('should navigate back to search when return button is clicked', async () => { - const defaultProps = { - availableAccountTypes: [], - handleConsentGoBack: vi.fn(), - handleOAuthGoBack: vi.fn(), - handleCredentialsGoBack: vi.fn(), - navigationRef: vi.fn(), - onManualAccountAdded: vi.fn(), - onUpsertMember: vi.fn(), - setConnectLocalState: vi.fn(), - } + const nonDemoInstitution = { + ...mockInstitution, + is_demo: false, + } as unknown as InstitutionResponseType - const mockInstitution = { - guid: 'INS-123', - name: 'Test Bank', - logo_url: 'https://example.com/logo.png', - code: 'TEST', - url: 'https://testbank.com', + const state = { + ...createRenderConnectStepInitialState(STEPS.ENTER_CREDENTIALS, nonDemoInstitution), + profiles: { + ...initialState.profiles, + user: { ...initialState.profiles.user, is_demo: true }, + }, } - const initialState = createRenderConnectStepInitialState( - STEPS.DEMO_CONNECT_GUARD, - mockInstitution as unknown as InstitutionResponseType, - ) - const { user } = render(, { - preloadedState: initialState, + preloadedState: state, }) - const returnButton = screen.getByRole('button', { name: /return to institution selection/i }) + const returnButton = screen.getByRole('button', { name: /go back/i }) await user.click(returnButton) expect(await screen.findByText(/Select your institution/i)).toBeInTheDocument() }) + + it('sends BACK_TO_SEARCH post message when Go back is clicked', async () => { + const onPostMessage = vi.fn() + + const nonDemoInstitution = { + ...mockInstitution, + is_demo: false, + } as unknown as InstitutionResponseType + const state = { + ...createRenderConnectStepInitialState(STEPS.ENTER_CREDENTIALS, nonDemoInstitution), + profiles: { + ...initialState.profiles, + user: { ...initialState.profiles.user, is_demo: true }, + }, + } + + const { user } = render( + + + , + { preloadedState: state }, + ) + + await user.click(screen.getByRole('button', { name: /go back/i })) + + expect(onPostMessage).toHaveBeenCalledWith('connect/backToSearch') + }) }) diff --git a/src/views/demoConnectGuard/DemoConnectGuard.tsx b/src/views/demoConnectGuard/DemoConnectGuard.tsx index 516bd987b4..aa4ed1b1ad 100644 --- a/src/views/demoConnectGuard/DemoConnectGuard.tsx +++ b/src/views/demoConnectGuard/DemoConnectGuard.tsx @@ -1,5 +1,6 @@ -import React from 'react' +import React, { useImperativeHandle, useContext } from 'react' import { useDispatch, useSelector } from 'react-redux' +import { RootState } from 'src/redux/Store' import { Button } from '@mui/material' import { P, H2 } from '@mxenabled/mxui' import { Icon } from '@mxenabled/mxui' @@ -11,51 +12,70 @@ import { getSelectedInstitution } from 'src/redux/selectors/Connect' import * as connectActions from 'src/redux/actions/Connect' import { selectInitialConfig } from 'src/redux/reducers/configSlice' import styles from 'src/views/demoConnectGuard/DemoConnectGuard.module.css' +import { PostMessageContext } from 'src/ConnectWidget' +import { POST_MESSAGES } from 'src/const/postMessages' -export const DemoConnectGuard: React.FC = () => { - const institution = useSelector(getSelectedInstitution) - const initialConfig = useSelector(selectInitialConfig) +export type DemoConnectGuardProps = { + showBackButton: () => boolean +} + +export const DemoConnectGuard = React.forwardRef( + function DemoConnectGuard(_, ref) { + const postMessageFunctions = useContext(PostMessageContext) + const institution = useSelector(getSelectedInstitution) + const initialConfig = useSelector(selectInitialConfig) + const location = useSelector((state: RootState) => state.connect.location) - const dispatch = useDispatch() + const dispatch = useDispatch() - return ( -
- -
- -
- + useImperativeHandle( + ref, + () => ({ + showBackButton: () => location.length > 1, + }), + [location.length], + ) + + return ( +
+ +
+ +
+ +
-
-

- {__('Demo mode active')} -

-

- - {__( - 'Live institutions are not available in the demo environment. Please select *MX Bank* to test the connection process.', - )} - -

- - -
- ) -} +

+ {__('Demo mode active')} +

+

+ + {__( + 'Live institutions are not available in the demo environment. Please select *MX Bank* to test the connection process.', + )} + +

+ + +
+ ) + }, +)