diff --git a/app/services/proofing/aamva/applicant.rb b/app/services/proofing/aamva/applicant.rb index 24dece1cb46..c0e06c06107 100644 --- a/app/services/proofing/aamva/applicant.rb +++ b/app/services/proofing/aamva/applicant.rb @@ -32,6 +32,27 @@ module Aamva keyword_init: true, ).freeze + # @param applicant [Hash, Struct] + # @option applicant [String, nil] :uuid + # @option applicant [String, nil] :first_name + # @option applicant [String, nil] :middle_name + # @option applicant [String, nil] :last_name + # @option applicant [String, nil] :name_suffix + # @option applicant [String, nil] :dob + # @option applicant [String, nil] :sex + # @option applicant [Integer, nil] :height in inches + # @option applicant [String, nil] :weight + # @option applicant [String, nil] :eye_color + # @option applicant [String, nil] :address1 + # @option applicant [String, nil] :address2 + # @option applicant [String, nil] :city + # @option applicant [String, nil] :state + # @option applicant [String, nil] :zipcode + # @option applicant [String, nil] :state_id_number + # @option applicant [String, nil] :state_id_jurisdiction + # @option applicant [String, nil] :state_id_type + # @option applicant [String, nil] :state_id_issued + # @option applicant [String, nil] :state_id_expiration # @return [Applicant] def self.from_proofer_applicant(applicant) new( diff --git a/app/services/proofing/aamva/proofer.rb b/app/services/proofing/aamva/proofer.rb index 4763275cabd..82e393cfa2f 100644 --- a/app/services/proofing/aamva/proofer.rb +++ b/app/services/proofing/aamva/proofer.rb @@ -49,36 +49,42 @@ def initialize(config) @config = Config.new(config) end + # @param applicant [Hash] def proof(applicant) aamva_applicant = Aamva::Applicant.from_proofer_applicant(applicant) + verification_request = build_verification_request(aamva_applicant) + verification_response = verification_request.send + jurisdiction = applicant[:state_id_jurisdiction] - response = Aamva::VerificationClient.new( - config, - ).send_verification_request( - applicant: aamva_applicant, - ) - - build_result_from_response(response, applicant[:state_id_jurisdiction]) + build_result(verification_request:, verification_response:, jurisdiction:) rescue => exception Proofing::StateIdResult.new( - success: false, errors: {}, exception: exception, vendor_name: 'aamva:state_id', - transaction_id: nil, verified_attributes: [], - jurisdiction_in_maintenance_window: jurisdiction_in_maintenance_window?( - applicant[:state_id_jurisdiction], - ) + success: false, + errors: {}, + exception: exception, + vendor_name: 'aamva:state_id', + transaction_id: nil, + verified_attributes: [], + requested_attributes: requested_attributes(verification_request), + jurisdiction_in_maintenance_window: jurisdiction_in_maintenance_window?(jurisdiction), ) end private - def build_result_from_response(verification_response, jurisdiction) + def build_verification_request(applicant) + Aamva::VerificationClient.new(config) + .build_verification_request(applicant:) + end + + def build_result(verification_request:, verification_response:, jurisdiction:) Proofing::StateIdResult.new( success: successful?(verification_response), errors: parse_verification_errors(verification_response), exception: nil, vendor_name: 'aamva:state_id', transaction_id: verification_response.transaction_locator_id, - requested_attributes: requested_attributes(verification_response).index_with(1), + requested_attributes: requested_attributes(verification_request), verified_attributes: verified_attributes(verification_response), jurisdiction_in_maintenance_window: jurisdiction_in_maintenance_window?(jurisdiction), ) @@ -98,13 +104,23 @@ def parse_verification_errors(verification_response) errors end - def requested_attributes(verification_response) - attributes = verification_response - .verification_results.filter { |_, verified| !verified.nil? } + # @param verification_request [Proofing::Aamva::Request::VerificationRequest] + def requested_attributes(verification_request) + return if verification_request.nil? + present_attributes = verification_request + .requested_attributes + .compact + .filter { |_k, v| v == :present } .keys .to_set - normalize_address_attributes(attributes) + blank_attributes = verification_request + .requested_attributes + .filter { |_k, v| v == :missing } + .transform_values { |_v| 0 } + + normalized = normalize_address_attributes(present_attributes).index_with(1) + normalized.merge(blank_attributes) end def verified_attributes(verification_response) diff --git a/app/services/proofing/aamva/request/verification_request.rb b/app/services/proofing/aamva/request/verification_request.rb index 34dfae42a90..30dc750dc6c 100644 --- a/app/services/proofing/aamva/request/verification_request.rb +++ b/app/services/proofing/aamva/request/verification_request.rb @@ -10,6 +10,7 @@ module Proofing module Aamva module Request + RequestAttribute = Data.define(:xpath, :required).freeze class VerificationRequest CONTENT_TYPE = 'application/soap+xml;charset=UTF-8' DEFAULT_VERIFICATION_URL = @@ -17,20 +18,45 @@ class VerificationRequest SOAP_ACTION = '"http://aamva.org/dldv/wsdl/2.1/IDLDVService21/VerifyDriverLicenseData"' + VERIFICATION_REQUESTED_ATTRS = { + first_name: RequestAttribute.new(xpath: '//nc:PersonGivenName', required: true), + middle_name: RequestAttribute.new(xpath: '//nc:PersonMiddleName', required: false), + last_name: RequestAttribute.new('//nc:PersonSurName', true), + name_suffix: RequestAttribute.new('//nc:PersonNameSuffixText', false), + dob: RequestAttribute.new('//aa:PersonBirthDate', true), + address1: RequestAttribute.new('//nc:AddressDeliveryPointText', true), + address2: RequestAttribute.new('//nc:AddressDeliveryPointText[2]', false), + city: RequestAttribute.new('//nc:LocationCityName', true), + state: RequestAttribute.new('//nc:LocationStateUsPostalServiceCode', true), + zipcode: RequestAttribute.new('//nc:LocationPostalCode', true), + state_id_number: RequestAttribute.new('//nc:IdentificationID', true), + state_id_type: RequestAttribute.new('//aa:DocumentCategoryCode', false), + state_id_expiration: RequestAttribute.new('//aa:DriverLicenseExpirationDate', false), + state_id_jurisdiction: RequestAttribute.new('//aa:MessageDestinationId', true), + state_id_issued: RequestAttribute.new('//aa:DriverLicenseIssueDate', false), + eye_color: RequestAttribute.new('//aa:PersonEyeColorCode', false), + height: RequestAttribute.new('//aa:PersonHeightMeasure', false), + sex: RequestAttribute.new('//aa:PersonSexCode', false), + weight: RequestAttribute.new('//aa:PersonWeightMeasure', false), + }.freeze + extend Forwardable attr_reader :config, :body, :headers, :url + # @param applicant [Proofing::Aamva::Applicant] def initialize(config:, applicant:, session_id:, auth_token:) @config = config @applicant = applicant @transaction_id = session_id @auth_token = auth_token + @requested_attributes = {} @url = verification_url @body = build_request_body @headers = build_request_headers end + # @return [Proofing::Aamva::Response::VerificationResponse] def send Response::VerificationResponse.new( http_client.post(url, body, headers) do |req| @@ -46,9 +72,21 @@ def verification_url config.verification_url || DEFAULT_VERIFICATION_URL end + # The requested attributes in the applicant PII hash. Values are: + # - +:present+ - value present + # - +:missing+ - field is required, but value was blank + # + # @see Proofing::Aamva::Applicant#from_proofer_applicant for fields + # @return [Hash{Symbol => Symbol}] + def requested_attributes + { **@requested_attributes } + end + private - attr_reader :applicant, :transaction_id, :auth_token + # @return [Proofing::Aamva::Applicant] + attr_reader :applicant + attr_reader :transaction_id, :auth_token def http_client Faraday.new(request: { open_timeout: timeout, timeout: timeout }) do |faraday| @@ -57,9 +95,10 @@ def http_client end end - def add_user_provided_data_to_body + def add_user_provided_data_to_body(body) document = REXML::Document.new(body) - user_provided_data_map.each do |xpath, data| + user_provided_data_map.each do |attribute, data| + xpath = VERIFICATION_REQUESTED_ATTRS[attribute].xpath REXML::XPath.first(document, xpath).add_text(data) end @@ -126,17 +165,18 @@ def add_user_provided_data_to_body document, ) - @body = document.to_s + update_requested_attributes(document) + document.to_s end def add_state_id_type(id_type, document) category_code = case id_type - when 'drivers_license' - 1 - when 'drivers_permit' - 2 - when 'state_id_card' - 3 + when 'drivers_license' + 1 + when 'drivers_permit' + 2 + when 'state_id_card' + 3 end if category_code @@ -151,10 +191,10 @@ def add_state_id_type(id_type, document) def add_sex_code(sex_value, document) sex_code = case sex_value - when 'male' - 1 - when 'female' - 2 + when 'male' + 1 + when 'female' + 2 end if sex_code @@ -181,10 +221,22 @@ def add_optional_element(name, value:, document:, inside: nil, after: nil) end end + # @param document [REXML::Document] + def update_requested_attributes(document) + VERIFICATION_REQUESTED_ATTRS.each do |attribute, rule| + value = REXML::XPath.first(document, rule.xpath)&.text + if value.present? + @requested_attributes[attribute] = :present + elsif rule.required + @requested_attributes[attribute] = :missing + end + end + end + def build_request_body renderer = ERB.new(request_body_template) - @body = renderer.result(binding) - add_user_provided_data_to_body + tmp_body = renderer.result(binding) + add_user_provided_data_to_body(tmp_body) end def build_request_headers @@ -222,24 +274,24 @@ def transaction_locator_id def user_provided_data_map { - '//nc:IdentificationID' => state_id_number, - '//aa:MessageDestinationId' => message_destination_id, - '//nc:PersonGivenName' => applicant.first_name, - '//nc:PersonSurName' => applicant.last_name, - '//aa:PersonBirthDate' => applicant.dob, - '//nc:AddressDeliveryPointText' => applicant.address1, - '//nc:LocationCityName' => applicant.city, - '//nc:LocationStateUsPostalServiceCode' => applicant.state, - '//nc:LocationPostalCode' => applicant.zipcode, + state_id_number: state_id_number, + state_id_jurisdiction: message_destination_id, + first_name: applicant.first_name, + last_name: applicant.last_name, + dob: applicant.dob, + address1: applicant.address1, + city: applicant.city, + state: applicant.state, + zipcode: applicant.zipcode, } end def state_id_number case applicant.state_id_data.state_id_jurisdiction - when 'SC' - applicant.state_id_data.state_id_number.rjust(8, '0') - else - applicant.state_id_data.state_id_number + when 'SC' + applicant.state_id_data.state_id_number.rjust(8, '0') + else + applicant.state_id_data.state_id_number end end diff --git a/app/services/proofing/aamva/verification_client.rb b/app/services/proofing/aamva/verification_client.rb index 4f4818156f9..fb01331d1c9 100644 --- a/app/services/proofing/aamva/verification_client.rb +++ b/app/services/proofing/aamva/verification_client.rb @@ -10,13 +10,17 @@ def initialize(config) @config = config end - def send_verification_request(applicant:, session_id: nil) + def build_verification_request(applicant:, session_id: nil) Request::VerificationRequest.new( applicant: applicant, session_id: session_id, auth_token: auth_token, config: config, - ).send + ) + end + + def send_verification_request(applicant:, session_id: nil) + build_verification_request(applicant:, session_id:).send end private diff --git a/spec/services/proofing/aamva/proofer_spec.rb b/spec/services/proofing/aamva/proofer_spec.rb index 26a650347ce..3e08c2595be 100644 --- a/spec/services/proofing/aamva/proofer_spec.rb +++ b/spec/services/proofing/aamva/proofer_spec.rb @@ -2,15 +2,36 @@ require 'ostruct' RSpec.describe Proofing::Aamva::Proofer do - let(:aamva_applicant) do - Aamva::Applicant.from_proofer_applicant(state_id_data) - end + let(:attribute) { :unknown } let(:state_id_data) do { state_id_number: '1234567890', state_id_jurisdiction: 'VA', state_id_type: 'drivers_license', + state_id_issued: '2024-05-06', + state_id_expiration: '2034-10-29', + } + end + + let(:applicant) do + { + uuid: '1234-abcd-efgh', + first_name: 'Testy', + last_name: 'McTesterson', + middle_name: 'Spectacle', + name_suffix: 'III', + dob: '10/29/1942', + address1: '123 Sunnyside way', + address2: nil, + city: 'Sterling', + state: 'VA', + zipcode: '20176-1234', + eye_color: 'brn', + height: 63, + weight: 179, + sex: 'female', + **state_id_data, } end @@ -27,26 +48,28 @@ } end + let(:config) { AamvaFixtures.example_config } + subject do - described_class.new(AamvaFixtures.example_config.to_h) + described_class.new(config.to_h) end let(:verification_response) { AamvaFixtures.verification_response } before do - stub_request(:post, AamvaFixtures.example_config.auth_url) + stub_request(:post, config.auth_url) .to_return( { body: AamvaFixtures.security_token_response }, { body: AamvaFixtures.authentication_token_response }, ) - stub_request(:post, AamvaFixtures.example_config.verification_url) + stub_request(:post, config.verification_url) .to_return(body: verification_response) end describe '#proof' do describe 'individual attributes' do subject(:result) do - described_class.new(AamvaFixtures.example_config.to_h).proof(state_id_data) + described_class.new(config.to_h).proof(applicant.compact_blank) end def self.when_missing(&block) @@ -58,6 +81,10 @@ def self.when_missing(&block) ) end + before do + applicant[attribute] = nil + end + instance_eval(&block) end end @@ -81,6 +108,7 @@ def self.test_in_requested_attributes(logged_attribute = nil) it "does not stop #{logged_attribute} from appearing in requested_attributes" do expect(result.requested_attributes).to include(logged_attribute => 1) end + it 'does not itself appear in requested_attributes' do expect(result.requested_attributes).not_to include(attribute => 1) end @@ -187,7 +215,7 @@ def self.test_not_successful end describe '#state' do - let(:attribute) { :city } + let(:attribute) { :state } let(:match_indicator_name) { 'AddressStateCodeMatchIndicator' } when_unverified do @@ -444,7 +472,7 @@ def self.test_not_successful context 'when verification is successful' do it 'the result is successful' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.success?).to eq(true) # TODO: Find a better way to express this than errors @@ -475,7 +503,7 @@ def self.test_not_successful end it 'includes requested_attributes' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.requested_attributes).to eq( { dob: 1, @@ -483,6 +511,7 @@ def self.test_not_successful state_id_expiration: 1, state_id_number: 1, state_id_type: 1, + state_id_jurisdiction: 1, last_name: 1, first_name: 1, middle_name: 1, @@ -507,7 +536,7 @@ def self.test_not_successful end it 'the result should be failed' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.success?).to eq(false) expect(result.errors).to include(dob: ['UNVERIFIED']) @@ -536,12 +565,13 @@ def self.test_not_successful end it 'includes requested_attributes' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.requested_attributes).to eq( { dob: 1, state_id_expiration: 1, state_id_issued: 1, + state_id_jurisdiction: 1, state_id_number: 1, state_id_type: 1, last_name: 1, @@ -567,7 +597,7 @@ def self.test_not_successful end it 'the result should be failed' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.success?).to eq(false) expect(result.errors).to include(dob: ['MISSING']) @@ -596,11 +626,12 @@ def self.test_not_successful end it 'includes requested_attributes' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.requested_attributes).to eq( { state_id_expiration: 1, state_id_issued: 1, + state_id_jurisdiction: 1, state_id_number: 1, state_id_type: 1, last_name: 1, @@ -612,35 +643,12 @@ def self.test_not_successful sex: 1, weight: 1, eye_color: 1, + dob: 1, }, ) end end - context 'when issue / expiration present' do - let(:state_id_data) do - { - state_id_number: '1234567890', - state_id_jurisdiction: 'VA', - state_id_type: 'drivers_license', - state_id_issued: '2023-04-05', - state_id_expiration: '2030-01-02', - } - end - - it 'includes them' do - expect(Proofing::Aamva::Request::VerificationRequest).to receive(:new).with( - hash_including( - applicant: satisfy do |a| - expect(a.state_id_data.state_id_issued).to eql('2023-04-05') - expect(a.state_id_data.state_id_expiration).to eql('2030-01-02') - end, - ), - ) - subject.proof(state_id_data) - end - end - context 'when AAMVA throws an exception' do let(:exception) { RuntimeError.new } @@ -650,7 +658,7 @@ def self.test_not_successful end it 'includes exception in result' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.success?).to eq(false) expect(result.exception).to eq(exception) @@ -661,7 +669,7 @@ def self.test_not_successful let(:exception) { Proofing::TimeoutError.new } it 'returns false for mva exception attributes in result' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.success?).to eq(false) expect(result.exception).to eq(exception) @@ -680,7 +688,7 @@ def self.test_not_successful end it 'returns true for mva_unavailable?' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.success?).to eq(false) expect(result.exception).to eq(exception) @@ -699,7 +707,7 @@ def self.test_not_successful end it 'returns true for mva_system_error?' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.success?).to eq(false) expect(result.exception).to eq(exception) @@ -718,7 +726,7 @@ def self.test_not_successful end it 'returns true for mva_timeout?' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.success?).to eq(false) expect(result.exception).to eq(exception) @@ -735,7 +743,7 @@ def self.test_not_successful end it 'sets jurisdiction_in_maintenance_window to true' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.jurisdiction_in_maintenance_window?).to eq(true) end end @@ -749,7 +757,7 @@ def self.test_not_successful end it 'sets jurisdiction_in_maintenance_window to true' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.jurisdiction_in_maintenance_window?).to eq(true) end end @@ -761,7 +769,7 @@ def self.test_not_successful end it 'sets jurisdiction_in_maintenance_window to false' do - result = subject.proof(state_id_data) + result = subject.proof(applicant) expect(result.jurisdiction_in_maintenance_window?).to eq(false) end end diff --git a/spec/services/proofing/aamva/request/verification_request_spec.rb b/spec/services/proofing/aamva/request/verification_request_spec.rb index 1a9a5865bd0..3a24ee8a7cd 100644 --- a/spec/services/proofing/aamva/request/verification_request_spec.rb +++ b/spec/services/proofing/aamva/request/verification_request_spec.rb @@ -1,26 +1,40 @@ require 'rails_helper' RSpec.describe Proofing::Aamva::Request::VerificationRequest do + let(:auth_token) { 'KEYKEYKEY' } + let(:transaction_id) { '1234-abcd-efgh' } + let(:config) { AamvaFixtures.example_config } let(:state_id_jurisdiction) { 'CA' } let(:state_id_number) { '123456789' } - let(:applicant) do - Proofing::Aamva::Applicant.from_proofer_applicant( + + let(:applicant_data) do + { uuid: '1234-abcd-efgh', first_name: 'Testy', + middle_name: nil, last_name: 'McTesterson', + name_suffix: nil, dob: '10/29/1942', address1: '123 Sunnyside way', + address2: nil, city: 'Sterling', state: 'VA', zipcode: '20176-1234', + eye_color: nil, + height: nil, + weight: nil, + sex: nil, state_id_number: state_id_number, state_id_jurisdiction: state_id_jurisdiction, state_id_type: 'drivers_license', - ) + state_id_expiration: nil, + state_id_issued: nil, + } + end + + let(:applicant) do + Proofing::Aamva::Applicant.from_proofer_applicant(**applicant_data.compact_blank) end - let(:auth_token) { 'KEYKEYKEY' } - let(:transaction_id) { '1234-abcd-efgh' } - let(:config) { AamvaFixtures.example_config } subject do described_class.new( @@ -73,6 +87,8 @@ applicant.zipcode, ], ) + + expect(subject.requested_attributes).to include(address2: :present) end it 'includes issue date if present' do @@ -80,6 +96,7 @@ expect(subject.body).to include( '2024-05-06', ) + expect(subject.requested_attributes).to include(state_id_issued: :present) end it 'includes expiration date if present' do @@ -87,6 +104,7 @@ expect(subject.body).to include( '2030-01-02', ) + expect(subject.requested_attributes).to include(state_id_expiration: :present) end it 'includes height if it is present' do @@ -94,6 +112,7 @@ expect(subject.body).to include( '63', ) + expect(subject.requested_attributes).to include(height: :present) end it 'includes weight if it is present' do @@ -101,6 +120,7 @@ expect(subject.body).to include( '190', ) + expect(subject.requested_attributes).to include(weight: :present) end it 'includes eye_color if it is present' do @@ -108,6 +128,7 @@ expect(subject.body).to include( 'blu', ) + expect(subject.requested_attributes).to include(eye_color: :present) end it 'includes name_suffix if it is present' do @@ -115,6 +136,7 @@ expect(subject.body).to include( 'JR', ) + expect(subject.requested_attributes).to include(name_suffix: :present) end it 'includes middle_name if it is present' do @@ -122,6 +144,7 @@ expect(subject.body).to include( 'test_name', ) + expect(subject.requested_attributes).to include(middle_name: :present) end context '#sex' do @@ -131,6 +154,7 @@ expect(subject.body).to include( '1', ) + expect(subject.requested_attributes).to include(:sex) end end @@ -140,6 +164,7 @@ expect(subject.body).to include( '2', ) + expect(subject.requested_attributes).to include(:sex) end end @@ -147,6 +172,15 @@ it 'does not send a sex code value' do applicant.sex = nil expect(subject.body).to_not include('') + expect(subject.requested_attributes).to_not include(:sex) + end + end + + context 'when the sex is unsupported' do + it 'does not send a sex code value' do + applicant.sex = 'X' + expect(subject.body).to_not include('') + expect(subject.requested_attributes).to_not include(:sex) end end end @@ -158,6 +192,7 @@ expect(subject.body).to include( '1', ) + expect(subject.requested_attributes).to include(:state_id_type) end end @@ -167,6 +202,7 @@ expect(subject.body).to include( '2', ) + expect(subject.requested_attributes).to include(:state_id_type) end end @@ -176,6 +212,7 @@ expect(subject.body).to include( '3', ) + expect(subject.requested_attributes).to include(:state_id_type) end end @@ -183,11 +220,13 @@ it 'does not add a DocumentCategoryCode for nil ID type' do applicant.state_id_data.state_id_type = nil expect(subject.body).to_not include('') + expect(subject.requested_attributes).to_not include(:state_id_type) end it 'does not add a DocumentCategoryCode for invalid ID types' do applicant.state_id_data.state_id_type = 'License to Keep an Alpaca' expect(subject.body).to_not include('') + expect(subject.requested_attributes).to_not include(:state_id_type) end end end @@ -258,8 +297,46 @@ end end + describe '#requested_attributes' do + let(:applicant_data) do + { + first_name: 'Testy', + last_name: 'McTesterson', + dob: '10/29/1942', + address1: '123 Sunnyside way', + city: 'Sterling', + state: 'VA', + zipcode: '20176-1234', + state_id_number: '98765421', + state_id_jurisdiction: 'VA', + state_id_type: 'drivers_license', + } + end + + it 'should set present fields to :present' do + expect(subject.requested_attributes).to match( + first_name: :present, + last_name: :present, + dob: :present, + address1: :present, + city: :present, + state: :present, + zipcode: :present, + state_id_number: :present, + state_id_type: :present, + state_id_jurisdiction: :present, + ) + end + + it 'should set required blank fields to :missing' do + applicant.first_name = nil + expect(subject.requested_attributes).to include(first_name: :missing) + end + end + describe 'South Carolina id number padding' do let(:state_id_jurisdiction) { 'SC' } + let(:rendered_state_id_number) do body = REXML::Document.new(subject.body) REXML::XPath.first(body, '//nc:IdentificationID')&.text