diff --git a/app/swagger/swagger/requests/travel_pay.rb b/app/swagger/swagger/requests/travel_pay.rb index ac3b209bf3c..a5303eb53a8 100644 --- a/app/swagger/swagger/requests/travel_pay.rb +++ b/app/swagger/swagger/requests/travel_pay.rb @@ -5,7 +5,7 @@ module Requests class TravelPay include Swagger::Blocks - swagger_path '/travel_pay/claims' do + swagger_path '/travel_pay/v0/claims' do operation :get do extend Swagger::Responses::AuthenticationError extend Swagger::Responses::BadRequestError @@ -31,6 +31,35 @@ class TravelPay end end end + + swagger_path '/travel_pay/v0/claims/{id}' do + operation :get do + extend Swagger::Responses::AuthenticationError + extend Swagger::Responses::BadRequestError + extend Swagger::Responses::RecordNotFoundError + + key :description, 'Get a single travel reimbursment claim summary' + key :operationId, 'getTravelPayClaimById' + key :tags, %w[travel_pay] + + parameter :authorization + + parameter do + key :name, 'id' + key :in, :path + key :description, 'The non-PII/PHI id of a claim (UUIDv4)' + key :required, true + key :type, :string + end + + response 200 do + key :description, 'Successfully retrieved claim for a user' + schema do + key :$ref, :TravelPayClaimSummary + end + end + end + end end end end diff --git a/modules/travel_pay/app/controllers/travel_pay/v0/claims_controller.rb b/modules/travel_pay/app/controllers/travel_pay/v0/claims_controller.rb index 157cfaaf793..1554bff216d 100644 --- a/modules/travel_pay/app/controllers/travel_pay/v0/claims_controller.rb +++ b/modules/travel_pay/app/controllers/travel_pay/v0/claims_controller.rb @@ -14,6 +14,28 @@ def index render json: claims, status: :ok end + def show + unless Flipper.enabled?(:travel_pay_view_claim_details, @current_user) + message = 'Travel Pay Claim Details unavailable per feature toggle' + raise Common::Exceptions::ServiceUnavailable, message: + end + + begin + token_service.get_tokens(@current_user) => { veis_token:, btsss_token: } + claim = claims_service.get_claim_by_id(veis_token, btsss_token, params[:id]) + rescue Faraday::Error => e + TravelPay::ServiceError.raise_mapped_error(e) + rescue ArgumentError => e + raise Common::Exceptions::BadRequest, message: e.message + end + + if claim.nil? + raise Common::Exceptions::ResourceNotFound, message: "Claim not found. ID provided: #{params[:id]}" + end + + render json: claim, status: :ok + end + private def claims_service diff --git a/modules/travel_pay/app/services/travel_pay/claims_service.rb b/modules/travel_pay/app/services/travel_pay/claims_service.rb index c31d2a7cec0..b0f1e4050fa 100644 --- a/modules/travel_pay/app/services/travel_pay/claims_service.rb +++ b/modules/travel_pay/app/services/travel_pay/claims_service.rb @@ -16,6 +16,26 @@ def get_claims(veis_token, btsss_token, params = {}) } end + def get_claim_by_id(veis_token, btsss_token, claim_id) + # ensure claim ID is the right format + uuid_v4_format = /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i + + unless uuid_v4_format.match?(claim_id) + raise ArgumentError, message: "Expected claim id to be a valid v4 UUID, got #{claim_id}." + end + + claims_response = client.get_claims(veis_token, btsss_token) + + claims = claims_response.body['data'] + + claim = claims.find { |c| c['id'] == claim_id } + + if claim + claim['claimStatus'] = claim['claimStatus'].underscore.titleize + claim + end + end + private def filter_by_date(date_string, claims) diff --git a/modules/travel_pay/config/routes.rb b/modules/travel_pay/config/routes.rb index 34f50abcbc1..95f4860e1dc 100644 --- a/modules/travel_pay/config/routes.rb +++ b/modules/travel_pay/config/routes.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true TravelPay::Engine.routes.draw do - # TODO: remove this mapping once vets-website - # is pointing to the /v0/claims routes - resources :claims, controller: '/travel_pay/v0/claims' - namespace :v0 do resources :claims end diff --git a/modules/travel_pay/spec/requests/travel_pay/claims_spec.rb b/modules/travel_pay/spec/requests/travel_pay/claims_spec.rb index 652df78d644..b90334c843e 100644 --- a/modules/travel_pay/spec/requests/travel_pay/claims_spec.rb +++ b/modules/travel_pay/spec/requests/travel_pay/claims_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rails_helper' +require 'securerandom' RSpec.describe TravelPay::V0::ClaimsController, type: :request do let(:user) { build(:user) } @@ -19,71 +20,84 @@ ] end - context '(Older) unversioned API route' do - it 'responds with 200' do + it 'responds with 200' do + VCR.use_cassette('travel_pay/200_claims', match_requests_on: %i[method path]) do + get '/travel_pay/v0/claims', params: nil, headers: { 'Authorization' => 'Bearer vagov_token' } + expect(response).to have_http_status(:ok) + claim_ids = JSON.parse(response.body)['data'].pluck('id') + expect(claim_ids).to eq(expected_claim_ids) + end + end + + context 'filtering claims' do + it 'returns a subset of claims' do + params = { 'appt_datetime' => '2024-04-09' } + headers = { 'Authorization' => 'Bearer vagov_token' } + VCR.use_cassette('travel_pay/200_claims', match_requests_on: %i[method path]) do - get '/travel_pay/claims', params: nil, headers: { 'Authorization' => 'Bearer vagov_token' } + get('/travel_pay/v0/claims', params:, headers:) expect(response).to have_http_status(:ok) claim_ids = JSON.parse(response.body)['data'].pluck('id') - expect(claim_ids).to eq(expected_claim_ids) + expect(claim_ids.length).to eq(1) + expect(claim_ids[0]).to eq('claim_id_2') end end - end - context 'Versioned v0 API route' do - it 'responds with 200' do + it 'returns all claims if params not passed' do VCR.use_cassette('travel_pay/200_claims', match_requests_on: %i[method path]) do get '/travel_pay/v0/claims', params: nil, headers: { 'Authorization' => 'Bearer vagov_token' } expect(response).to have_http_status(:ok) claim_ids = JSON.parse(response.body)['data'].pluck('id') - expect(claim_ids).to eq(expected_claim_ids) + expect(claim_ids.length).to eq(3) end end - context 'filtering claims' do - it 'returns a subset of claims' do - params = { 'appt_datetime' => '2024-04-09' } - headers = { 'Authorization' => 'Bearer vagov_token' } - - VCR.use_cassette('travel_pay/200_claims', match_requests_on: %i[method path]) do - get('/travel_pay/v0/claims', params:, headers:) - expect(response).to have_http_status(:ok) - claim_ids = JSON.parse(response.body)['data'].pluck('id') - expect(claim_ids.length).to eq(1) - expect(claim_ids[0]).to eq('claim_id_2') - end - end - - it 'returns all claims if params not passed' do - VCR.use_cassette('travel_pay/200_claims', match_requests_on: %i[method path]) do - get '/travel_pay/v0/claims', params: nil, headers: { 'Authorization' => 'Bearer vagov_token' } - expect(response).to have_http_status(:ok) - claim_ids = JSON.parse(response.body)['data'].pluck('id') - expect(claim_ids.length).to eq(3) - end - end - end - end - end - - context 'unsuccessful response from API' do - context '(Older) unversioned API route' do it 'responds with a 404 if the API endpoint is not found' do VCR.use_cassette('travel_pay/404_claims', match_requests_on: %i[method path]) do - get '/travel_pay/claims', params: nil, headers: { 'Authorization' => 'Bearer vagov_token' } + get '/travel_pay/v0/claims', params: nil, headers: { 'Authorization' => 'Bearer vagov_token' } expect(response).to have_http_status(:bad_request) end end end + end + end - context 'Versioned v0 API route' do - it 'responds with a 404 if the API endpoint is not found' do - VCR.use_cassette('travel_pay/404_claims', match_requests_on: %i[method path]) do - get '/travel_pay/v0/claims', params: nil, headers: { 'Authorization' => 'Bearer vagov_token' } - expect(response).to have_http_status(:bad_request) - end - end + describe '#show' do + before do + Flipper.enable(:travel_pay_view_claim_details) + end + + it 'returns a single claim on success' do + VCR.use_cassette('travel_pay/show/success', match_requests_on: %i[method path]) do + # This claim ID matches a claim ID in the cassette. + claim_id = '33016896-ed7f-4d4f-a81b-cc4f2ca0832c' + expected_claim_num = 'TC092809828275' + + get "/travel_pay/v0/claims/#{claim_id}", headers: { 'Authorization' => 'Bearer vagov_token' } + actual_claim_num = JSON.parse(response.body)['claimNumber'] + + expect(response).to have_http_status(:ok) + expect(actual_claim_num).to eq(expected_claim_num) end end + + it 'returns a Not Found response if claim number valid but claim not found' do + VCR.use_cassette('travel_pay/show/success', match_requests_on: %i[method path]) do + # This claim ID matches a claim ID in the cassette. + claim_id = SecureRandom.uuid + + get "/travel_pay/v0/claims/#{claim_id}", headers: { 'Authorization' => 'Bearer vagov_token' } + + expect(response).to have_http_status(:not_found) + end + end + + it 'returns a ServiceUnavailable response if feature flag turned off' do + Flipper.disable(:travel_pay_view_claim_details) + + get '/travel_pay/v0/claims/123', headers: { 'Authorization' => 'Bearer vagov_token' } + + expect(response).to have_http_status(:service_unavailable) + end end end diff --git a/modules/travel_pay/spec/services/claims_service_spec.rb b/modules/travel_pay/spec/services/claims_service_spec.rb index 067aa49d29c..b897742698c 100644 --- a/modules/travel_pay/spec/services/claims_service_spec.rb +++ b/modules/travel_pay/spec/services/claims_service_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rails_helper' +require 'securerandom' describe TravelPay::ClaimsService do context 'get_claims' do @@ -36,8 +37,8 @@ 'modifiedOn' => '2024-02-01T00:00:00.0Z' }, { - 'id' => 'uuid4', - 'claimNumber' => '73611905-71bf-46ed-b1ec-e790593b8565', + 'id' => '73611905-71bf-46ed-b1ec-e790593b8565', + 'claimNumber' => 'TC0004', 'claimName' => '9d81c1a1-cd05-47c6-be97-d14dec579893', 'claimStatus' => 'Claim Submitted', 'appointmentDateTime' => nil, @@ -73,6 +74,33 @@ expect(actual_statuses).to match_array(expected_statuses) end + context 'get claim by id' do + it 'returns a single claim when passed a valid id' do + claim_id = '73611905-71bf-46ed-b1ec-e790593b8565' + expected_claim = claims_data['data'].find { |c| c['id'] == claim_id } + service = TravelPay::ClaimsService.new + actual_claim = service.get_claim_by_id(*tokens, claim_id) + + expect(actual_claim).to eq(expected_claim) + end + + it 'returns nil if a claim with the given id was not found' do + claim_id = SecureRandom.uuid + service = TravelPay::ClaimsService.new + actual_claim = service.get_claim_by_id(*tokens, claim_id) + + expect(actual_claim).to eq(nil) + end + + it 'throws an ArgumentException if claim_id is invalid format' do + claim_id = 'this-is-definitely-a-uuid-right' + service = TravelPay::ClaimsService.new + + expect { service.get_claim_by_id(*tokens, claim_id) } + .to raise_error(ArgumentError, /valid v4 UUID/i) + end + end + context 'filter by appt date' do it 'returns claims that match appt date if specified' do service = TravelPay::ClaimsService.new diff --git a/spec/requests/swagger_spec.rb b/spec/requests/swagger_spec.rb index 76785036298..d7d65c381a2 100644 --- a/spec/requests/swagger_spec.rb +++ b/spec/requests/swagger_spec.rb @@ -3282,20 +3282,70 @@ let(:mhv_user) { build(:user, :loa3) } it 'returns unauthorized for unauthed user' do - expect(subject).to validate(:get, '/travel_pay/claims', 401) + expect(subject).to validate(:get, '/travel_pay/v0/claims', 401) end it 'returns 400 for invalid request' do headers = { '_headers' => { 'Cookie' => sign_in(mhv_user, nil, true) } } VCR.use_cassette('travel_pay/404_claims', match_requests_on: %i[host path method]) do - expect(subject).to validate(:get, '/travel_pay/claims', 400, headers) + expect(subject).to validate(:get, '/travel_pay/v0/claims', 400, headers) end end it 'returns 200 for successful response' do headers = { '_headers' => { 'Cookie' => sign_in(mhv_user, nil, true) } } VCR.use_cassette('travel_pay/200_claims', match_requests_on: %i[host path method]) do - expect(subject).to validate(:get, '/travel_pay/claims', 200, headers) + expect(subject).to validate(:get, '/travel_pay/v0/claims', 200, headers) + end + end + end + + context 'show' do + let(:mhv_user) { build(:user, :loa3) } + + it 'returns unauthorized for unauthed user' do + expect(subject).to validate( + :get, + '/travel_pay/v0/claims/{id}', + 401, + {}.merge('id' => '24e227ea-917f-414f-b60d-48b7743ee95d') + ) + end + + it 'returns 404 for missing claim' do + headers = { '_headers' => { 'Cookie' => sign_in(mhv_user, nil, true) } } + VCR.use_cassette('travel_pay/show/success', match_requests_on: %i[path method]) do + expect(subject).to validate( + :get, + '/travel_pay/v0/claims/{id}', + 404, + headers.merge('id' => '8656ad4e-5cdf-41e2-bbd5-af843d2fa8fe') + ) + end + end + + it 'returns 400 for invalid request' do + headers = { '_headers' => { 'Cookie' => sign_in(mhv_user, nil, true) } } + VCR.use_cassette('travel_pay/show/success', match_requests_on: %i[path method]) do + expect(subject).to validate( + :get, + '/travel_pay/v0/claims/{id}', + 400, + headers.merge('id' => '8656') + ) + end + end + + it 'returns 200 for successful response' do + headers = { '_headers' => { 'Cookie' => sign_in(mhv_user, nil, true) } } + claim_id = '33016896-ed7f-4d4f-a81b-cc4f2ca0832c' + VCR.use_cassette('travel_pay/show/success', match_requests_on: %i[path method]) do + expect(subject).to validate( + :get, + '/travel_pay/v0/claims/{id}', + 200, + headers.merge('id' => claim_id) + ) end end end diff --git a/spec/support/vcr_cassettes/travel_pay/show/success.yml b/spec/support/vcr_cassettes/travel_pay/show/success.yml new file mode 100644 index 00000000000..ec9e9669f2d --- /dev/null +++ b/spec/support/vcr_cassettes/travel_pay/show/success.yml @@ -0,0 +1,374 @@ +--- +http_interactions: +- request: + method: post + uri: "/tenant_id/oauth2/token" + body: + encoding: US-ASCII + string: 'client_id=client_id&client_secret=client_secret&client_info=1&grant_type=client_credentials&resource=resource_id' + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - Vets.gov Agent + Authorization: + - Bearer blahblahblah + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 28 Feb 2023 21:02:39 GMT + Content-Type: + - application/json; charset=utf-8 + Connection: + - keep-alive + X-Ratelimit-Remaining-Minute: + - '59' + X-Ratelimit-Limit-Minute: + - '60' + Ratelimit-Remaining: + - '59' + Ratelimit-Limit: + - '60' + Ratelimit-Reset: + - '25' + Etag: + - W/"6571c42e57529000188d704a3cd1f46a" + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Origin + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Git-Sha: + - b20885293917fd081d24899644d2718d2ab4ccf9 + X-Github-Repository: + - https://github.com/department-of-veterans-affairs/vets-api + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - d687047e-5004-43c1-babb-c2f52f2fda40 + X-Runtime: + - '3.569014' + X-Xss-Protection: + - 1; mode=block + Access-Control-Allow-Origin: + - "*" + X-Kong-Upstream-Latency: + - '3573' + X-Kong-Proxy-Latency: + - '24' + Via: + - kong/3.0.2 + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cache-Control: + - no-cache, no-store + Pragma: + - no-cache + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{ + "data": { + "access_token": "string", + "contactId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + }' + recorded_at: Tue, 28 Feb 2023 21:02:39 GMT +- request: + method: get + uri: https://btsss.gov/api/v1/claims + body: + encoding: US-ASCII + string: '' + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - Vets.gov Agent + Authorization: + - Bearer string + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 28 Feb 2023 21:02:39 GMT + Content-Type: + - application/json; charset=utf-8 + Connection: + - keep-alive + X-Ratelimit-Remaining-Minute: + - '59' + X-Ratelimit-Limit-Minute: + - '60' + Ratelimit-Remaining: + - '59' + Ratelimit-Limit: + - '60' + Ratelimit-Reset: + - '25' + Etag: + - W/"6571c42e57529000188d704a3cd1f46a" + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Origin + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Git-Sha: + - b20885293917fd081d24899644d2718d2ab4ccf9 + X-Github-Repository: + - https://github.com/department-of-veterans-affairs/vets-api + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - d687047e-5004-43c1-babb-c2f52f2fda40 + X-Runtime: + - '3.569014' + X-Xss-Protection: + - 1; mode=block + Access-Control-Allow-Origin: + - "*" + X-Kong-Upstream-Latency: + - '3573' + X-Kong-Proxy-Latency: + - '24' + Via: + - kong/3.0.2 + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cache-Control: + - no-cache, no-store + Pragma: + - no-cache + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{ + "data": [ + { + "id": "f3bd4991-5935-44d2-b247-5feadb7c94db", + "claimNumber": "TC0928098230498", + "claimStatus": "InProcess", + "appointmentDateTime": "2024-04-22T16:45:34.465Z", + "facilityName": "Cheyenne VA Medical Center", + "createdOn": "2024-04-22T21:22:34.465Z", + "modifiedOn": "2024-04-23T16:44:34.465Z" + }, + { + "id": "a116c1ef-3eac-4dd9-b860-02768be1c234", + "claimNumber": "TC0928098228366", + "claimStatus": "Incomplete", + "appointmentDateTime": "2024-04-09T20:15:34.465Z", + "facilityName": "Cheyenne VA Medical Center", + "createdOn": "2024-04-09T14:13:22.465Z", + "modifiedOn": "2024-04-09T20:29:34.465Z" + }, + { + "id": "33016896-ed7f-4d4f-a81b-cc4f2ca0832c", + "claimNumber": "TC092809828275", + "claimStatus": "InManualReview", + "appointmentDateTime": "2024-04-13T20:30:34.465Z", + "facilityName": "Cheyenne VA Medical Center", + "createdOn": "2024-04-13T15:55:57.465Z", + "modifiedOn": "2024-04-14T18:40:34.465Z" + } + ] + }' + recorded_at: Tue, 28 Feb 2023 21:02:39 GMT +- request: + method: post + uri: https://www.example.com/v0/sign_in/token + body: + encoding: US-ASCII + string: '' + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - Vets.gov Agent + Authorization: + - Bearer blahblahblah + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 28 Feb 2023 21:02:39 GMT + Content-Type: + - application/json; charset=utf-8 + Connection: + - keep-alive + X-Ratelimit-Remaining-Minute: + - '59' + X-Ratelimit-Limit-Minute: + - '60' + Ratelimit-Remaining: + - '59' + Ratelimit-Limit: + - '60' + Ratelimit-Reset: + - '25' + Etag: + - W/"6571c42e57529000188d704a3cd1f46a" + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Origin + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Git-Sha: + - b20885293917fd081d24899644d2718d2ab4ccf9 + X-Github-Repository: + - https://github.com/department-of-veterans-affairs/vets-api + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - d687047e-5004-43c1-babb-c2f52f2fda40 + X-Runtime: + - '3.569014' + X-Xss-Protection: + - 1; mode=block + Access-Control-Allow-Origin: + - "*" + X-Kong-Upstream-Latency: + - '3573' + X-Kong-Proxy-Latency: + - '24' + Via: + - kong/3.0.2 + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cache-Control: + - no-cache, no-store + Pragma: + - no-cache + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{ + "data": { + "access_token": "sts_token" + } + }' + recorded_at: Tue, 28 Feb 2023 21:02:39 GMT +- request: + method: post + uri: https://btsss.gov/api/v1/Auth/access-token + body: + encoding: US-ASCII + string: '' + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - Vets.gov Agent + Authorization: + - Bearer blahblahblah + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 28 Feb 2023 21:02:39 GMT + Content-Type: + - application/json; charset=utf-8 + Connection: + - keep-alive + X-Ratelimit-Remaining-Minute: + - '59' + X-Ratelimit-Limit-Minute: + - '60' + Ratelimit-Remaining: + - '59' + Ratelimit-Limit: + - '60' + Ratelimit-Reset: + - '25' + Etag: + - W/"6571c42e57529000188d704a3cd1f46a" + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Origin + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Git-Sha: + - b20885293917fd081d24899644d2718d2ab4ccf9 + X-Github-Repository: + - https://github.com/department-of-veterans-affairs/vets-api + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - d687047e-5004-43c1-babb-c2f52f2fda40 + X-Runtime: + - '3.569014' + X-Xss-Protection: + - 1; mode=block + Access-Control-Allow-Origin: + - "*" + X-Kong-Upstream-Latency: + - '3573' + X-Kong-Proxy-Latency: + - '24' + Via: + - kong/3.0.2 + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cache-Control: + - no-cache, no-store + Pragma: + - no-cache + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{ + "data": { + "accessToken": "btsss_token" + } + }' + recorded_at: Tue, 28 Feb 2023 21:02:39 GMT +recorded_with: VCR 6.1.0