diff --git a/app/controllers/idv/in_person/usps_locations_controller.rb b/app/controllers/idv/in_person/usps_locations_controller.rb index 5135df024d0..6f70cfe5cdc 100644 --- a/app/controllers/idv/in_person/usps_locations_controller.rb +++ b/app/controllers/idv/in_person/usps_locations_controller.rb @@ -14,7 +14,6 @@ class UspsLocationsController < ApplicationController # retrieve the list of nearby IPP Post Office locations with a POST request def index response = [] - begin if IdentityConfig.store.arcgis_search_enabled candidate = UspsInPersonProofing::Applicant.new( @@ -26,12 +25,17 @@ def index else response = proofer.request_pilot_facilities end + render json: response.to_json rescue => err - Rails.logger.warn(err) - response = proofer.request_pilot_facilities + analytics.idv_in_person_locations_request_failure( + exception_class: err.class, + exception_message: err.message, + response_body_present: err.respond_to?(:response_body) && err.response_body.present?, + response_body: err.respond_to?(:response_body) && err.response_body, + response_status_code: err.respond_to?(:response_status) && err.response_status, + ) + render json: {}, status: :internal_server_error end - - render json: response.to_json end def proofer diff --git a/app/javascript/packages/document-capture/components/address-search.spec.tsx b/app/javascript/packages/document-capture/components/address-search.spec.tsx index 65fd4760774..9c525b420ac 100644 --- a/app/javascript/packages/document-capture/components/address-search.spec.tsx +++ b/app/javascript/packages/document-capture/components/address-search.spec.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; import type { SetupServerApi } from 'msw/node'; +import { SWRConfig } from 'swr'; import AddressSearch, { ADDRESS_SEARCH_URL, LOCATIONS_URL } from './address-search'; const DEFAULT_RESPONSE = [ @@ -40,10 +41,12 @@ describe('AddressSearch', () => { const handleAddressFound = sandbox.stub(); const handleLocationsFound = sandbox.stub(); const { findByText, findByLabelText } = render( - , + new Map() }}> + + , ); await userEvent.type( diff --git a/app/javascript/packages/document-capture/components/address-search.tsx b/app/javascript/packages/document-capture/components/address-search.tsx index f060775eae4..945438c2d9b 100644 --- a/app/javascript/packages/document-capture/components/address-search.tsx +++ b/app/javascript/packages/document-capture/components/address-search.tsx @@ -139,15 +139,18 @@ function useUspsLocations() { } }, [addressCandidates]); - const { data: locationResults, isLoading: isLoadingLocations } = useSWR( - [foundAddress], - ([address]) => (address ? requestUspsLocations(address) : null), - { keepPreviousData: true }, - ); + const { + data: locationResults, + isLoading: isLoadingLocations, + error, + } = useSWR([foundAddress], ([address]) => (address ? requestUspsLocations(address) : null), { + keepPreviousData: true, + }); return { foundAddress, locationResults, + error, isLoading: isLoadingLocations || isLoadingCandidates, handleAddressSearch, validatedFieldRef, @@ -158,18 +161,21 @@ interface AddressSearchProps { registerField?: RegisterFieldCallback; onFoundAddress?: (address: LocationQuery | null) => void; onFoundLocations?: (locations: FormattedLocation[] | null | undefined) => void; + onError?: (error: Error | null) => void; } function AddressSearch({ registerField = () => undefined, onFoundAddress = () => undefined, onFoundLocations = () => undefined, + onError = () => undefined, }: AddressSearchProps) { const { t } = useI18n(); const spinnerButtonRef = useRef(null); const [textInput, setTextInput] = useState(''); const { locationResults, + error, isLoading, handleAddressSearch: onSearch, foundAddress, @@ -185,6 +191,10 @@ function AddressSearch({ spinnerButtonRef.current?.toggleSpinner(isLoading); }, [isLoading]); + useEffect(() => { + error && onError(error); + }, [error]); + useDidUpdateEffect(() => { onFoundLocations(locationResults); @@ -193,6 +203,7 @@ function AddressSearch({ const handleSearch = useCallback( (event) => { + onError(null); onFoundAddress(null); onSearch(event, textInput); }, diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx new file mode 100644 index 00000000000..f39cb9ecea4 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx @@ -0,0 +1,142 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import type { SetupServerApi } from 'msw/node'; +import { LOCATIONS_URL } from './in-person-location-step'; +import { ADDRESS_SEARCH_URL } from './address-search'; +import InPersonContext from '../context/in-person'; +import InPersonLocationPostOfficeSearchStep from './in-person-location-post-office-search-step'; + +const DEFAULT_RESPONSE = [ + { + address: '100 Main St E, Bronwood, Georgia, 39826', + location: { + latitude: 31.831686000000005, + longitude: -84.363768, + }, + street_address: '100 Main St E', + city: 'Bronwood', + state: 'GA', + zip_code: '39826', + }, +]; + +const DEFAULT_PROPS = { + toPreviousStep() {}, + onChange() {}, + value: {}, + registerField() {}, +}; + +describe('InPersonLocationStep', () => { + context('initial API request throws an error', () => { + let server: SetupServerApi; + beforeEach(() => { + server = setupServer( + rest.post(ADDRESS_SEARCH_URL, (_req, res, ctx) => res(ctx.json(DEFAULT_RESPONSE))), + rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.status(500))), + ); + server.listen(); + }); + + afterEach(() => { + server.close(); + }); + + it('displays a 500 error if the request to the USPS API throws an error', async () => { + const { findByText, findByLabelText } = render( + + + , + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), + '222 Merchandise Mart Plaza', + ); + + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + const error = await findByText('idv.failure.exceptions.internal_error'); + expect(error).to.exist(); + }); + }); + + context('initial API request is successful', () => { + let server: SetupServerApi; + beforeEach(() => { + server = setupServer( + rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), + rest.post(ADDRESS_SEARCH_URL, (_req, res, ctx) => res(ctx.json(DEFAULT_RESPONSE))), + ); + server.listen(); + }); + + afterEach(() => { + server.close(); + }); + + it('allows search by address when enabled', async () => { + const { findByText, findByLabelText } = render( + + + , + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), + '100 main', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + await findByText('in_person_proofing.body.location.po_search.results_description'); + }); + + it('validates input and shows inline error', async () => { + const { findByText } = render( + + + , + ); + + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + await findByText('in_person_proofing.body.location.inline_error'); + }); + + it('displays no post office results if a successful search is followed by an unsuccessful search', async () => { + const { findByText, findByLabelText, queryByRole } = render( + + + , + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), + '594 Broadway New York', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), + 'asdfkf', + ); + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + const results = queryByRole('status', { + name: 'in_person_proofing.body.location.po_search.results_description', + }); + expect(results).not.to.exist(); + }); + }); +}); diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx index 3922247c082..3a4fb7bf22e 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef, useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; -import { PageHeading } from '@18f/identity-components'; +import { Alert, PageHeading } from '@18f/identity-components'; import { request } from '@18f/identity-request'; import BackButton from './back-button'; import AnalyticsContext from '../context/analytics'; @@ -21,6 +21,7 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist null, ); const [foundAddress, setFoundAddress] = useState(null); + const [uspsError, setUspsError] = useState(null); // ref allows us to avoid a memory leak const mountedRef = useRef(false); @@ -77,12 +78,18 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist return ( <> + {uspsError && ( + + {t('idv.failure.exceptions.internal_error')} + + )} {t('in_person_proofing.headings.po_search.location')}

{t('in_person_proofing.body.location.po_search.po_search_about')}

{locationResults && foundAddress && ( { await findByText('{"selected_location":"Baltimore"}'); }); - - it('allows search by address when enabled', async () => { - const { findByText, findByLabelText } = render( - - - , - ); - - await userEvent.type( - await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), - '100 main', - ); - await userEvent.click( - await findByText('in_person_proofing.body.location.po_search.search_button'), - ); - await findByText('in_person_proofing.body.location.po_search.results_description'); - }); - - it('validates input and shows inline error', async () => { - const { findByText } = render( - - - , - ); - - await userEvent.click( - await findByText('in_person_proofing.body.location.po_search.search_button'), - ); - - await findByText('in_person_proofing.body.location.inline_error'); - }); - - it('displays no post office results if a successful search is followed by an unsuccessful search', async () => { - const { findByText, findByLabelText, queryByRole } = render( - - - , - ); - - await userEvent.type( - await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), - '594 Broadway New York', - ); - await userEvent.click( - await findByText('in_person_proofing.body.location.po_search.search_button'), - ); - - await userEvent.type( - await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), - 'asdfkf', - ); - await userEvent.click( - await findByText('in_person_proofing.body.location.po_search.search_button'), - ); - - const results = queryByRole('status', { - name: 'in_person_proofing.body.location.po_search.results_description', - }); - expect(results).not.to.exist(); - }); }); diff --git a/app/javascript/packages/request/index.ts b/app/javascript/packages/request/index.ts index b7854df46cb..927a71def66 100644 --- a/app/javascript/packages/request/index.ts +++ b/app/javascript/packages/request/index.ts @@ -41,5 +41,9 @@ export async function request( } const response = await window.fetch(url, { ...fetchOptions, headers, body }); - return json ? response.json() : response.text(); + if (response.ok) { + return json ? response.json() : response.text(); + } + + throw new Error(await response.json()); } diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 9dc13a15d25..0fd82711eab 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -2978,6 +2978,31 @@ def user_registration_email_confirmation( ) end + # Tracks if request to get USPS in-person proofing locations fails + # @param [String] exception_class + # @param [String] exception_message + # @param [Boolean] response_body_present + # @param [Hash] response_body + # @param [Integer] response_status_code + def idv_in_person_locations_request_failure( + exception_class:, + exception_message:, + response_body_present:, + response_body:, + response_status_code:, + **extra + ) + track_event( + 'Request USPS IPP locations: request failed', + exception_class: exception_class, + exception_message: exception_message, + response_body_present: response_body_present, + response_body: response_body, + response_status_code: response_status_code, + **extra, + ) + end + # Tracks when USPS in-person proofing enrollment is created # @param [String] enrollment_code # @param [Integer] enrollment_id diff --git a/package.json b/package.json index 62ecc844f87..d3da7a50078 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "sinon-chai": "^3.7.0", "stylelint": "^14.13.0", "svgo": "^2.8.0", + "swr": "^2.0.0", "typescript": "^4.8.4", "webpack-dev-server": "^4.11.1", "whatwg-fetch": "^3.4.0" diff --git a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb index 4382f76307d..5ae3e124c80 100644 --- a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb +++ b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb @@ -103,18 +103,21 @@ context 'with arcgis search enabled' do context 'with a nil address in params' do + let(:param_error) { ActionController::ParameterMissing.new(param: address) } + before do - allow(proofer).to receive(:request_pilot_facilities).and_return(pilot_locations) + allow(proofer).to receive(:request_facilities).with(address).and_raise(param_error) end subject(:response) do post :index, params: { address: nil } end - it 'returns the pilot locations' do + it 'returns no locations' do + subject json = response.body facilities = JSON.parse(json) - expect(facilities.length).to eq 4 + expect(facilities.length).to eq 0 end end @@ -130,24 +133,58 @@ end end - context 'with unsuccessful fetch' do - let(:exception) { Faraday::ConnectionFailed } + context 'with a timeout from Faraday' do + let(:timeout_error) { Faraday::TimeoutError.new } + + before do + allow(proofer).to receive(:request_facilities).with(address).and_raise(timeout_error) + end + + it 'returns an internal server error' do + subject + expect(@analytics).to have_logged_event( + 'Request USPS IPP locations: request failed', + exception_class: timeout_error.class, + exception_message: timeout_error.message, + response_body_present: + timeout_error.response_body.present?, + response_body: timeout_error.response_body, + response_status_code: timeout_error.response_status, + ) + + status = response.status + expect(status).to eq 500 + end + end + + context 'with failed connection to Faraday' do + let(:exception) { Faraday::ConnectionFailed.new } + subject(:response) do + post :index, + params: { address: { street_address: '742 Evergreen Terrace', + city: 'Springfield', + state: 'MO', + zip_code: '89011' } } + end before do allow(proofer).to receive(:request_facilities).with(fake_address).and_raise(exception) - allow(proofer).to receive(:request_pilot_facilities).and_return(pilot_locations) end - it 'returns all pilot locations' do - expect(Rails.logger).to receive(:warn) - response = post :index, - params: { address: { street_address: '742 Evergreen Terrace', - city: 'Springfield', - state: 'MO', - zip_code: '89011' } } - json = response.body - facilities = JSON.parse(json) - expect(facilities.length).to eq 4 + it 'returns no locations' do + subject + expect(@analytics).to have_logged_event( + 'Request USPS IPP locations: request failed', + exception_class: exception.class, + exception_message: exception.message, + response_body_present: + exception.response_body.present?, + response_body: exception.response_body, + response_status_code: exception.response_status, + ) + + facilities = JSON.parse(response.body) + expect(facilities.length).to eq 0 end end end