From e0aa0fe825031b664be8fa287abf7d79b07a0426 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Fri, 21 Nov 2025 16:11:55 -0700 Subject: [PATCH 1/2] fix: handle loading directly to an unavailable institution it should load to the status details page, not the credentials page --- src/hooks/useLoadConnect.tsx | 3 + src/redux/reducers/Connect.js | 37 ++++++++-- src/redux/reducers/__tests__/Connect-test.js | 76 ++++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/hooks/useLoadConnect.tsx b/src/hooks/useLoadConnect.tsx index d2e17724b7..8bbd416c60 100644 --- a/src/hooks/useLoadConnect.tsx +++ b/src/hooks/useLoadConnect.tsx @@ -17,6 +17,7 @@ import { useApi, ApiContextTypes } from 'src/context/ApiContext' import { __ } from 'src/utilities/Intl' import type { RootState } from 'src/redux/Store' import { instutionSupportRequestedProducts } from 'src/utilities/Institution' +import { getExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice' export const getErrorResource = (err: { config: { url: string | string[] } }) => { if (err.config?.url.includes('/institutions')) { @@ -47,6 +48,7 @@ export const getErrorResource = (err: { config: { url: string | string[] } }) => const useLoadConnect = () => { const { api } = useApi() const profiles = useSelector((state: RootState) => state.profiles) + const experimentalFeatures = useSelector(getExperimentalFeatures) const clientLocale = useMemo(() => { return document.querySelector('html')?.getAttribute('lang') || 'en' }, [document.querySelector('html')?.getAttribute('lang')]) @@ -77,6 +79,7 @@ const useLoadConnect = () => { return from(api.loadMembers(clientLocale)).pipe( map((members = []) => loadConnectSuccess({ + experimentalFeatures, members, widgetProfile: profiles.widgetProfile, ...dependencies, diff --git a/src/redux/reducers/Connect.js b/src/redux/reducers/Connect.js index 2a68614b04..0bc93e513a 100644 --- a/src/redux/reducers/Connect.js +++ b/src/redux/reducers/Connect.js @@ -59,6 +59,7 @@ const loadConnectSuccess = (state, action) => { microdeposit, config = {}, institution = {}, + experimentalFeatures = {}, widgetProfile, } = action.payload @@ -70,7 +71,15 @@ const loadConnectSuccess = (state, action) => { isComponentLoading: false, location: pushLocation( state.location, - getStartingStep(members, member, microdeposit, config, institution, widgetProfile), + getStartingStep( + members, + member, + microdeposit, + config, + institution, + widgetProfile, + experimentalFeatures, + ), ), selectedInstitution: institution, updateCredentials: @@ -520,7 +529,24 @@ const upsertMember = (state, action) => { return [...state.members, loadedMember] } -function getStartingStep(members, member, microdeposit, config, institution, widgetProfile) { +function getStartingStep( + members, + member, + microdeposit, + config, + institution, + widgetProfile, + experimentalFeatures = {}, +) { + // Unavailable institutions experimental feature: Make sure we don't load a user + // directly to an institution that should be unavailable. + const unavailableInstitutions = experimentalFeatures?.unavailableInstitutions || [] + const institutionIsAvailable = + institution && + unavailableInstitutions.find( + (ins) => ins.guid === institution?.guid || ins.name === institution?.name, + ) === undefined + const shouldStepToMFA = member && config.update_credentials && member.connection_status === ReadableStatuses.CHALLENGED const shouldUpdateCredentials = @@ -530,13 +556,16 @@ function getStartingStep(members, member, microdeposit, config, institution, wid config.mode === VERIFY_MODE && microdeposit.status !== MicrodepositsStatuses.PREINITIATED const shouldLoadWithInstitution = - institution && (config.current_institution_guid || config.current_institution_code) + institution && + (config.current_institution_guid || config.current_institution_code) && + institutionIsAvailable const shouldStepToConnecting = member?.connection_status === ReadableStatuses.REJECTED || member?.connection_status === ReadableStatuses.EXPIRED const shouldStepToInstitutionStatusDetails = (institution && institutionIsBlockedForCostReasons(institution)) || - (member && memberIsBlockedForCostReasons(member)) + (member && memberIsBlockedForCostReasons(member)) || + !institutionIsAvailable if (shouldStepToInstitutionStatusDetails) { return STEPS.INSTITUTION_STATUS_DETAILS diff --git a/src/redux/reducers/__tests__/Connect-test.js b/src/redux/reducers/__tests__/Connect-test.js index d89843d143..578f50ec13 100644 --- a/src/redux/reducers/__tests__/Connect-test.js +++ b/src/redux/reducers/__tests__/Connect-test.js @@ -312,6 +312,82 @@ describe('Connect redux store', () => { expect(afterState.members).toHaveLength(2) expect(afterState.members[0]).toEqual({ guid: 'MBR-1', institution_guid: 'INST-1' }) }) + + it('should show the institutionStatusDetails step if the configured institution is blocked for fees/costs', () => { + const afterState = reducer( + defaultState, + loadConnectSuccess({ + config: { current_institution_guid: 'INS-1' }, + institution: { guid: 'INS-1', name: 'Chase Bank', is_disabled_by_client: true }, + widgetProfile: {}, + }), + ) + + expect(afterState.location[afterState.location.length - 1].step).toEqual( + STEPS.INSTITUTION_STATUS_DETAILS, + ) + }) + + it('should show the credentials step if the configured institution is not blocked by the client for fees/costs', () => { + const afterState = reducer( + defaultState, + loadConnectSuccess({ + config: { current_institution_guid: 'INS-1' }, + institution: { guid: 'INS-1', name: 'Chase Bank', is_disabled_by_client: false }, + widgetProfile: {}, + }), + ) + + expect(afterState.location[afterState.location.length - 1].step).toEqual( + STEPS.ENTER_CREDENTIALS, + ) + }) + + it('should show the institutionStatusDetails step if the configured institution is unavailable', () => { + const afterState = reducer( + defaultState, + loadConnectSuccess({ + institution: { guid: 'INS-1', name: 'Unavailable Bank' }, + experimentalFeatures: { + unavailableInstitutions: [{ guid: 'INS-1', name: 'Unavailable Bank' }], + }, + }), + ) + + expect(afterState.location[afterState.location.length - 1].step).toEqual( + STEPS.INSTITUTION_STATUS_DETAILS, + ) + }) + + it('should show the credentials step if the configured institution_guid is available', () => { + const afterState = reducer( + defaultState, + loadConnectSuccess({ + config: { current_institution_guid: 'INS-1' }, + institution: { guid: 'INS-1', name: 'Unavailable Bank' }, + widgetProfile: {}, + }), + ) + + expect(afterState.location[afterState.location.length - 1].step).toEqual( + STEPS.ENTER_CREDENTIALS, + ) + }) + + it('should show the credentials step if the configured institution code is available', () => { + const afterState = reducer( + defaultState, + loadConnectSuccess({ + config: { current_institution_code: 'unavailable_bank' }, + institution: { guid: 'INS-1', name: 'Unavailable Bank' }, + widgetProfile: {}, + }), + ) + + expect(afterState.location[afterState.location.length - 1].step).toEqual( + STEPS.ENTER_CREDENTIALS, + ) + }) }) describe('loadConnectError', () => { From 571bf894d74ca82dda41f9b57c3fc26c82fef69c Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Mon, 8 Dec 2025 14:31:57 -0700 Subject: [PATCH 2/2] test: exercise the unavailable institution flow via the loadConnect hook --- src/hooks/__tests__/useLoadConnect-test.tsx | 43 +++++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/hooks/__tests__/useLoadConnect-test.tsx b/src/hooks/__tests__/useLoadConnect-test.tsx index 5ddeab80a7..676cc00b25 100644 --- a/src/hooks/__tests__/useLoadConnect-test.tsx +++ b/src/hooks/__tests__/useLoadConnect-test.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import type { RootState } from 'src/redux/Store' import { screen, render } from 'src/utilities/testingLibrary' import useLoadConnect from 'src/hooks/useLoadConnect' @@ -9,10 +9,13 @@ import { ApiProvider } from 'src/context/ApiContext' import { apiValue } from 'src/const/apiProviderMock' import { ConfigError } from 'src/components/ConfigError' import { COMBO_JOB_DATA_TYPES } from 'src/const/comboJobDataTypes' +import { loadExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice' -const TestLoadConnectComponent: React.FC<{ clientConfig: ClientConfigType }> = ({ - clientConfig, -}) => { +const TestLoadConnectComponent: React.FC<{ + clientConfig: ClientConfigType + experimentalFeatures?: { unavailableInstitutions: { guid: string; name: string }[] } +}> = ({ clientConfig, experimentalFeatures }) => { + const dispatch = useDispatch() const step = useSelector( (state: RootState) => state.connect.location[state.connect.location.length - 1]?.step ?? STEPS.SEARCH, @@ -21,6 +24,7 @@ const TestLoadConnectComponent: React.FC<{ clientConfig: ClientConfigType }> = ( const { loadConnect } = useLoadConnect() useEffect(() => { + dispatch(loadExperimentalFeatures(experimentalFeatures || {})) loadConnect(clientConfig) }, []) @@ -35,6 +39,8 @@ const TestLoadConnectComponent: React.FC<{ clientConfig: ClientConfigType }> = ( return

Search

} else if (step === STEPS.ENTER_CREDENTIALS) { return

Enter credentials

+ } else if (step === STEPS.INSTITUTION_STATUS_DETAILS) { + return

Institution status details

} else { return

Search

} @@ -302,4 +308,33 @@ describe('useLoadConnect', () => { ), ).toBeInTheDocument() }) + + it('will return the INSTITUTION_STATUS_DETAILS step if the state contains a configured unavailable institution', async () => { + const mockApi = { + ...apiValue, + loadInstitutionByGuid: vi.fn().mockResolvedValue( + Promise.resolve({ + ...institutionData.institution, + guid: 'INS-unavailable', + name: 'Unavailable Bank', + }), + ), + } + render( + + + , + ) + expect(await screen.findByText(/Institution status details/i)).toBeInTheDocument() + }) })