From 7ccb7b4eb0c09b87153686106a11ec8b0856c26e Mon Sep 17 00:00:00 2001 From: Ian Norris Date: Wed, 13 May 2026 14:42:49 -0700 Subject: [PATCH] [FFS-4307, FFS-4308] support VA implementation support SSN and non SSN based lookup, as well as disability determinations --- .../app/controllers/application_controller.rb | 7 ++ rails-app/app/models/current.rb | 3 + .../veteran/disability_rating_response.rb | 22 ++-- .../app/services/reporting/report_data.rb | 8 +- .../veteran/disability_rating_mapper.rb | 20 ++-- rails-app/app/services/veteran/va_client.rb | 52 +++++---- .../services/education/nsc_client_test.rb | 39 ------- .../test/services/veteran/va_client_test.rb | 102 ++++++++++++++---- 8 files changed, 141 insertions(+), 112 deletions(-) create mode 100644 rails-app/app/models/current.rb diff --git a/rails-app/app/controllers/application_controller.rb b/rails-app/app/controllers/application_controller.rb index 4ac8823..654a3bf 100644 --- a/rails-app/app/controllers/application_controller.rb +++ b/rails-app/app/controllers/application_controller.rb @@ -1,2 +1,9 @@ class ApplicationController < ActionController::API + before_action :set_current_request_id + + private + + def set_current_request_id + Current.request_id = request.request_id + end end diff --git a/rails-app/app/models/current.rb b/rails-app/app/models/current.rb new file mode 100644 index 0000000..1f26f21 --- /dev/null +++ b/rails-app/app/models/current.rb @@ -0,0 +1,3 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :request_id +end diff --git a/rails-app/app/models/veteran/disability_rating_response.rb b/rails-app/app/models/veteran/disability_rating_response.rb index eb2bfb3..c9c0930 100644 --- a/rails-app/app/models/veteran/disability_rating_response.rb +++ b/rails-app/app/models/veteran/disability_rating_response.rb @@ -1,27 +1,25 @@ module Veteran class DisabilityRatingResponse - attr_accessor :combined_disability_rating, :combined_effective_date, :legal_effective_date, - :earliest_rating_end_date, :raw_data, :data_source, :metadata + attr_accessor :raw_data, :data_source, :metadata, + :total_disability_status, :total_disability_status_effective_date def initialize(params = {}) - @combined_disability_rating = params[:combinedDisabilityRating] - @combined_effective_date = params[:combinedEffectiveDate] - @legal_effective_date = params[:legalEffectiveDate] - @earliest_rating_end_date = params[:earliestRatingEndDate] @raw_data = params[:rawData] @data_source = params[:dataSource] @metadata = params[:metadata] + @total_disability_status = params[:totalDisabilityStatus] + @total_disability_status_effective_date = params[:totalDisabilityStatusEffectiveDate] + @permanent_and_total_disability_status = params[:permanentAndTotalDisabilityStatus] + @permanent_and_total_disability_pension_award_status = params[:permanentAndTotalDisabilityPensionAwardStatus] end def as_json(options = {}) { - combinedDisabilityRating: combined_disability_rating, - combinedEffectiveDate: combined_effective_date, - legalEffectiveDate: legal_effective_date, - earliestRatingEndDate: earliest_rating_end_date, - rawData: raw_data, + totalDisabilityStatus: total_disability_status, + totalDisabilityStatusEffectiveDate: total_disability_status_effective_date, dataSource: data_source, - metadata: metadata + metadata: metadata, + rawData: raw_data } end end diff --git a/rails-app/app/services/reporting/report_data.rb b/rails-app/app/services/reporting/report_data.rb index 17b5807..93ea406 100644 --- a/rails-app/app/services/reporting/report_data.rb +++ b/rails-app/app/services/reporting/report_data.rb @@ -1,14 +1,15 @@ module Reporting class ReportData - attr_accessor :timestamp, :endpoint, :data_source, :client_id, :status_code, :success + attr_accessor :timestamp, :endpoint, :data_source, :client_id, :status_code, :success, :transaction_id - def initialize(timestamp:, endpoint:, data_source:, client_id:, status_code:, success:) + def initialize(timestamp:, endpoint:, data_source:, client_id:, status_code:, success:, transaction_id: Current.request_id) @timestamp = timestamp @endpoint = endpoint @data_source = data_source @client_id = client_id @status_code = status_code @success = success + @transaction_id = transaction_id end def to_json(*_args) @@ -18,7 +19,8 @@ def to_json(*_args) data_source: @data_source, client_id: @client_id, status_code: @status_code, - success: @success + success: @success, + transaction_id: @transaction_id }.to_json end end diff --git a/rails-app/app/services/veteran/disability_rating_mapper.rb b/rails-app/app/services/veteran/disability_rating_mapper.rb index 05c5757..290e0c6 100644 --- a/rails-app/app/services/veteran/disability_rating_mapper.rb +++ b/rails-app/app/services/veteran/disability_rating_mapper.rb @@ -1,21 +1,13 @@ module Veteran class DisabilityRatingMapper - def self.map_response(va_resp, datasource_duration, start_time) - attributes = va_resp.dig('data', 'attributes') || {} - - individual_ratings = attributes['individual_ratings'] || [] - earliest_end_date = individual_ratings.map { |r| r['rating_end_date'] } - .reject { |d| d.blank? } - .min - + def self.map_response(total_disability_response, datasource_duration, start_time) response_timestamp = Time.now + total_disabilty_data = total_disability_response.dig("data") Veteran::DisabilityRatingResponse.new( - combinedDisabilityRating: attributes['combined_disability_rating'], - combinedEffectiveDate: attributes['combined_effective_date'], - legalEffectiveDate: attributes['legal_effective_date'], - earliestRatingEndDate: earliest_end_date, - rawData: va_resp, + totalDisabilityStatus: total_disabilty_data["total_disability"]["status"], + totalDisabilityStatusEffectiveDate: total_disabilty_data["total_disability"]["effective_date"], + rawData: total_disability_response, dataSource: "VA", metadata: { apiVersion: ENV['SERVICE_VERSION'] || '1.3.0', @@ -23,7 +15,7 @@ def self.map_response(va_resp, datasource_duration, start_time) requestTimestamp: start_time.utc.iso8601(3), responseTimestamp: response_timestamp.utc.iso8601(3), datasourceDurationMillis: datasource_duration.to_i, - transactionId: SecureRandom.uuid + transactionId: Current.request_id || SecureRandom.uuid } ) end diff --git a/rails-app/app/services/veteran/va_client.rb b/rails-app/app/services/veteran/va_client.rb index d99d130..246e8f6 100644 --- a/rails-app/app/services/veteran/va_client.rb +++ b/rails-app/app/services/veteran/va_client.rb @@ -6,50 +6,60 @@ module Veteran class NotFoundError < StandardError; end class VaClient - DISABILITY_RATING_PATH = "/restricted/disability_rating".freeze - DISABILITY_RATING_SCOPE = "disability_rating_restricted.read".freeze + DISABILITY_RATING_PATH = "/disability_rating".freeze + RESTRICTED_DISABILITY_RATING_PATH = "/restricted/disability_rating".freeze + DISABILITY_RATING_SCOPE = "disability_rating_restricted.read disability_rating.read permanent_and_total_disability.read permanent_and_total_disability_restricted.read".freeze CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".freeze + TOTAL_DISABILITY_PATH = "/permanent_and_total_disability" + TOTAL_DISABILITY_RESTRICTED_PATH = "/restricted/permanent_and_total_disability" def initialize end + def lookup_disability_rating(req_params) start_time = Time.now - - rating_req = Veteran::DisabilityRatingRequest.new(req_params) token = fetch_oauth_token - va_payload = rating_req.to_va_payload + rating_req = Veteran::DisabilityRatingRequest.new(req_params) - uri = URI(File.join(ENV['VA_BASE_URL'], DISABILITY_RATING_PATH)) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = (uri.scheme == 'https') + total_disability_response = get_total_disability_response(rating_req, token) + datasource_duration = (Time.now - start_time) * 1000 + + Veteran::DisabilityRatingMapper.map_response(JSON.parse(total_disability_response.body), datasource_duration, start_time) + end - request = Net::HTTP::Post.new(uri.path, { + private + def get_total_disability_response(rating_req, token) + va_payload = rating_req.to_va_payload + if rating_req.can_use_restricted_endpoint? + total_disability_uri = URI(File.join(ENV['VA_BASE_URL'], TOTAL_DISABILITY_RESTRICTED_PATH)) + else + total_disability_uri = URI(File.join(ENV['VA_BASE_URL'], TOTAL_DISABILITY_PATH)) + end + http = Net::HTTP.new(total_disability_uri.host, total_disability_uri.port) + http.use_ssl = (total_disability_uri.scheme == 'https') + + total_disability_request = Net::HTTP::Post.new(total_disability_uri.path, { 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Authorization' => "Bearer #{token}" }) - request.body = va_payload.to_json - datasource_start_time = Time.now - response = http.request(request) - datasource_duration = (Time.now - datasource_start_time) * 1000 + total_disability_request.body = va_payload.to_json - if response.code.to_i == 404 + total_disability_response = http.request(total_disability_request) + if total_disability_response.code.to_i == 404 raise NotFoundError, "veteran not found" end - if response.code.to_i < 200 || response.code.to_i >= 300 - Rails.logger.error("VA disability rating failed: status=#{response.code} body=#{response.body.to_s[0..800]}") - raise "VA disability rating failed: status=#{response.code}" + if total_disability_response.code.to_i < 200 || total_disability_response.code.to_i >= 300 + Rails.logger.error("VA disability rating failed: status=#{total_disability_response.code} body=#{total_disability_response.body.to_s[0..800]}") + raise "VA disability rating failed: status=#{total_disability_response.code}" end - va_resp = JSON.parse(response.body) - Veteran::DisabilityRatingMapper.map_response(va_resp, datasource_duration, start_time) + return total_disability_response end - private - def fetch_oauth_token assertion = signed_client_assertion diff --git a/rails-app/test/services/education/nsc_client_test.rb b/rails-app/test/services/education/nsc_client_test.rb index 862ae16..d053f50 100644 --- a/rails-app/test/services/education/nsc_client_test.rb +++ b/rails-app/test/services/education/nsc_client_test.rb @@ -157,44 +157,5 @@ def stub_nsc_requests(oauth_resp, submit_resp) end end end - - test 'circuit breaker trips after failures' do - # Mock Stoplight to simulate failure and then open circuit - # Since Stoplight seems to be a method provided by the gem, we access it via the module if possible - # or just skip this specific test if the environment is too weird. - # Let's try to find where Light is. - light_class = if defined?(::Stoplight::Light) - ::Stoplight::Light - elsif defined?(Stoplight::Domain::Light) - # This doesn't seem right for configuration - nil - end - - skip "Stoplight::Light not defined" unless light_class - - old_data_store = light_class.default_data_store - light_class.default_data_store = Stoplight::DataStore::Memory.new - - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - submit_response = Net::HTTPBadGateway.new('1.1', '502', 'Bad Gateway') - - # Threshold is 3 by default. Let's trip it. - 3.times do - stub_nsc_requests(oauth_response, submit_response) do - assert_raises(StandardError) { @coordinator.lookup_enrollment_status(@req_body) } - end - end - - # 4th call should raise Stoplight::Error::RedLight without even trying Net::HTTP - assert_raises(Stoplight::Error::RedLight) do - @coordinator.lookup_enrollment_status(@req_body) - end - ensure - if defined?(light_class) && light_class && old_data_store - light_class.default_data_store = old_data_store - end - end end end diff --git a/rails-app/test/services/veteran/va_client_test.rb b/rails-app/test/services/veteran/va_client_test.rb index 19fd2db..b96f1ab 100644 --- a/rails-app/test/services/veteran/va_client_test.rb +++ b/rails-app/test/services/veteran/va_client_test.rb @@ -31,6 +31,7 @@ class VaClientTest < ActiveSupport::TestCase dateOfBirth: '1988-10-24', ssn: '123456789' } + Current.request_id = 'test-request-id' end teardown do @@ -40,14 +41,22 @@ class VaClientTest < ActiveSupport::TestCase private - def stub_va_requests(oauth_resp, va_resp) + def stub_va_requests(oauth_resp, va_total_disability_resp = nil, rating_path: nil, total_disability_path: nil) http_mock_oauth = Minitest::Mock.new http_mock_oauth.expect :use_ssl=, true, [true] http_mock_oauth.expect :request, oauth_resp, [Net::HTTP::Post] http_mock_va = Minitest::Mock.new http_mock_va.expect :use_ssl=, true, [true] - http_mock_va.expect :request, va_resp, [Net::HTTP::Post] + + if va_total_disability_resp + total_disability_matcher = ->(req) { + req.is_a?(Net::HTTP::Post) && (total_disability_path.nil? || req.path == total_disability_path) + } + http_mock_va.expect :request, va_total_disability_resp do |req| + total_disability_matcher.call(req) + end + end calls = 0 Net::HTTP.stub :new, proc { |host, port| @@ -63,14 +72,14 @@ def stub_va_requests(oauth_resp, va_resp) public - test 'lookup_disability_rating success' do + test 'lookup_disability_rating success uses restricted endpoint when SSN is present' do oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') oauth_response.instance_variable_set(:@read, true) oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - va_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - va_response.instance_variable_set(:@read, true) - va_response.instance_variable_set(:@body, { + va_rating_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + va_rating_response.instance_variable_set(:@read, true) + va_rating_response.instance_variable_set(:@body, { data: { attributes: { combined_disability_rating: 100, @@ -84,14 +93,71 @@ def stub_va_requests(oauth_resp, va_resp) } }.to_json) - stub_va_requests(oauth_response, va_response) do - result = @client.lookup_disability_rating(@req_params) + va_total_disability_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + va_total_disability_response.instance_variable_set(:@read, true) + va_total_disability_response.instance_variable_set(:@body, { + data: { + total_disability: { + status: true, + effective_date: "2023-01-01" + }, + permanent_and_total: { + service_connected_status: false, + pension_award_status: false + } + } + }.to_json) + + stub_va_requests(oauth_response, va_total_disability_response, + rating_path: '/va/restricted/disability_rating', + total_disability_path: '/va/restricted/permanent_and_total_disability') do + response = @client.lookup_disability_rating(@req_params) + assert_equal true, response.total_disability_status + assert_equal true, response.total_disability_status_effective_date.present? + assert_equal 'test-request-id', response.metadata[:transactionId] + end + end + + test 'lookup_disability_rating success uses standard endpoint when SSN is absent' do + params_without_ssn = @req_params.except(:ssn) + + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + oauth_response.instance_variable_set(:@read, true) + oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) + + va_rating_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + va_rating_response.instance_variable_set(:@read, true) + va_rating_response.instance_variable_set(:@body, { + data: { + attributes: { + combined_disability_rating: 70, + combined_effective_date: '2023-01-01', + legal_effective_date: '2023-01-01' + } + } + }.to_json) - assert_equal 100, result.combined_disability_rating - assert_equal '2023-01-01', result.combined_effective_date - assert_equal '2023-12-01', result.earliest_rating_end_date - assert_equal 'VA', result.data_source - assert_not_nil result.metadata + va_total_disability_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + va_total_disability_response.instance_variable_set(:@read, true) + va_total_disability_response.instance_variable_set(:@body, { + data: { + total_disability: { + status: true, + effective_date: "2023-01-01" + }, + permanent_and_total: { + service_connected_status: true, + pension_award_status: true + } + } + }.to_json) + + stub_va_requests(oauth_response, va_total_disability_response, + rating_path: '/va/disability_rating', + total_disability_path: '/va/permanent_and_total_disability') do + response = @client.lookup_disability_rating(params_without_ssn) + assert_equal true, response.total_disability_status + assert_equal true, response.total_disability_status_effective_date.present? end end @@ -108,15 +174,5 @@ def stub_va_requests(oauth_resp, va_resp) end end end - - test 'circuit breaker trips after failures' do - # In Stoplight 5.x, it's harder to mock the data store globally for just one test - # without affecting others or needing complex setup. - # Since the previous implementation skipped this if Stoplight::Light wasn't defined, - # and Stoplight 5.x doesn't define it at the top level, we'll keep the skip pattern - # but updated for the current version's reality if needed. - - skip "Stoplight 5.x circuit breaker testing not implemented" - end end end