diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/appeals_base_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/appeals_base_controller.rb new file mode 100644 index 00000000000..f6fc407ba71 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/appeals_base_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'caseflow/service' +require 'decision_reviews/v1/service' + +module DecisionReviews + module V1 + class AppealsBaseController < ApplicationController + include FailedRequestLoggable + before_action { authorize :appeals, :access? } + + private + + def decision_review_service + DecisionReviews::V1::Service.new + end + + def request_body_hash + @request_body_hash ||= get_hash_from_request_body + end + + def get_hash_from_request_body + # rubocop:disable Style/ClassEqualityComparison + # testing string b/c NullIO class doesn't always exist + raise request_body_is_not_a_hash_error if request.body.class.name == 'Puma::NullIO' + # rubocop:enable Style/ClassEqualityComparison + + body = JSON.parse request.body.string + raise request_body_is_not_a_hash_error unless body.is_a?(Hash) + + body + rescue JSON::ParserError + raise request_body_is_not_a_hash_error + end + + def request_body_is_not_a_hash_error + DecisionReviewV1::ServiceException.new key: 'DR_REQUEST_BODY_IS_NOT_A_HASH' + end + + def request_body_debug_data + { + request_body_class_name: request.try(:body).class.name, + request_body_string: request.try(:body).try(:string) + } + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/decision_review_evidences_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/decision_review_evidences_controller.rb new file mode 100644 index 00000000000..9b817733f41 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/decision_review_evidences_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'decision_reviews/v1/logging_utils' +require 'common/pdf_helpers' + +# Notice of Disagreement evidence submissions +module DecisionReviews + module V1 + class DecisionReviewEvidencesController < ApplicationController + include FormAttachmentCreate + include DecisionReviews::V1::LoggingUtils + service_tag 'evidence-upload' + + FORM_ATTACHMENT_MODEL = DecisionReviewEvidenceAttachment + + private + + def serializer_klass + DecisionReviewEvidenceAttachmentSerializer + end + + # This method, declared in `FormAttachmentCreate`, is responsible for uploading file data to S3. + def save_attachment_to_cloud! + # `form_attachment` is declared in `FormAttachmentCreate`, included above. + form_attachment_guid = form_attachment&.guid + password = filtered_params[:password] + + log_params = { + form_attachment_guid:, + encrypted: password.present? + } + + # Unlock pdf with hexapdf instead of using pdftk + if password.present? + unlocked_pdf = unlock_pdf(filtered_params[:file_data], password) + form_attachment.set_file_data!(unlocked_pdf) + else + super + end + + log_formatted(**common_log_params.merge(params: log_params, is_success: true)) + rescue => e + log_formatted(**common_log_params.merge(params: log_params, is_success: false, response_error: e)) + raise e + end + + def common_log_params + { + key: :evidence_upload_to_s3, + form_id: get_form_id_from_request_headers, + user_uuid: current_user.uuid, + downstream_system: 'AWS S3' + } + end + + def unlock_pdf(file, password) + tmpf = Tempfile.new(['decrypted_form_attachment', '.pdf']) + ::Common::PdfHelpers.unlock_pdf(file.tempfile.path, password, tmpf) + tmpf.rewind + + file.tempfile.unlink + file.tempfile = tmpf + file + end + + def get_form_id_from_request_headers + # 'Source-App-Name', which specifies the form from which evidence was submitted, is taken from `window.appName`, + # which is taken from the `entryName` in the manifest.json files for each form. See: + # - vets-website/src/platform/utilities/api/index.js (apiRequest) + # - vets-website/src/platform/startup/setup.js (setUpCommonFunctionality) + # - vets-website/src/platform/startup/index.js (startApp) + # - vets-api/lib/source_app_middleware.rb + source_app_name = request.env['SOURCE_APP'] + # The higher-level review form (996) is not included in this list because it does not permit evidence uploads. + form_id = { + '10182-board-appeal' => '10182', + '995-supplemental-claim' => '995' + }[source_app_name] + + if form_id.present? + form_id + else + # If, for some odd reason, the `entryName`s are changed in these manifest.json files (or if the HLR form + # begins accepting additional evidence), we will trigger a DataDog alert hinging on the StatsD metric below. + # Upon receiving this alert, we can update the form_id hash above. + StatsD.increment('decision_review.evidence_upload_to_s3.unexpected_form_id') + # In this situation, there is no good reason to block the Veteran from uploading their evidence to S3, + # so we return the unexpected `source_app_name` to be logged by `log_formatted` above. + source_app_name + end + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/decision_review_notification_callbacks_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/decision_review_notification_callbacks_controller.rb new file mode 100644 index 00000000000..a6d887eba81 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/decision_review_notification_callbacks_controller.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'decision_reviews/v1/logging_utils' + +module DecisionReviews + module V1 + class DecisionReviewNotificationCallbacksController < ApplicationController + include ActionController::HttpAuthentication::Token::ControllerMethods + include DecisionReviews::V1::LoggingUtils + + service_tag 'appeal-application' + + skip_before_action :verify_authenticity_token, only: [:create] + skip_before_action :authenticate, only: [:create] + skip_after_action :set_csrf_header, only: [:create] + before_action :authenticate_header, only: [:create] + + STATSD_KEY_PREFIX = 'api.decision_review.notification_callback' + + DELIVERED_STATUS = 'delivered' + + APPEAL_TYPE_TO_SERVICE_MAP = { + 'HLR' => 'higher-level-review', + 'NOD' => 'board-appeal', + 'SC' => 'supplemental-claims' + }.freeze + + VALID_FUNCTION_TYPES = %w[form evidence secondary_form].freeze + + def create + return render json: nil, status: :not_found unless enabled? + + payload = JSON.parse(request.body.string) + status = payload['status']&.downcase + reference = payload['reference'] + + StatsD.increment("#{STATSD_KEY_PREFIX}.received", tags: { status: }) + send_silent_failure_avoided_metric(reference) if status == DELIVERED_STATUS + + DecisionReviewNotificationAuditLog.create!(notification_id: payload['id'], reference:, status:, payload:) + + log_formatted(**log_params(payload, true)) + render json: { message: 'success' } + rescue => e + log_formatted(**log_params(payload, false), params: { exception_message: e.message }) + render json: { message: 'failed' } + end + + private + + def log_params(payload, is_success) + { + key: :decision_review_notification_callback, + form_id: '995', + user_uuid: nil, + upstream_system: 'VANotify', + body: payload.merge('to' => ''), # scrub PII from logs + is_success:, + params: { + notification_id: payload['id'], + status: payload['status'] + } + } + end + + def send_silent_failure_avoided_metric(reference) + service_name, function_type = parse_reference_value(reference) + tags = ["service:#{service_name}", "function: #{function_type} submission to Lighthouse"] + StatsD.increment('silent_failure_avoided', tags:) + rescue => e + Rails.logger.error('Failed to send silent_failure_avoided metric', params: { reference:, message: e.message }) + end + + def parse_reference_value(reference) + appeal_type, function_type = reference.split('-') + raise 'Invalid function_type' unless VALID_FUNCTION_TYPES.include? function_type + + [APPEAL_TYPE_TO_SERVICE_MAP.fetch(appeal_type.upcase), function_type] + end + + def authenticate_header + authenticate_user_with_token || authenticity_error + end + + def authenticate_user_with_token + authenticate_with_http_token do |token| + is_authenticated = token == bearer_token_secret + Rails.logger.info('DecisionReviewNotificationCallbacksController callback received', is_authenticated:) + + is_authenticated + end + end + + def authenticity_error + render json: { message: 'Invalid credentials' }, status: :unauthorized + end + + def bearer_token_secret + Settings.nod_vanotify_status_callback.bearer_token + end + + def enabled? + Flipper.enabled? :nod_callbacks_endpoint + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/higher_level_reviews/contestable_issues_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/higher_level_reviews/contestable_issues_controller.rb new file mode 100644 index 00000000000..9cfbf8633e4 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/higher_level_reviews/contestable_issues_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module DecisionReviews + module V1 + module HigherLevelReviews + class ContestableIssuesController < AppealsBaseController + service_tag 'higher-level-review' + + def index + ci = decision_review_service + .get_higher_level_review_contestable_issues(user: current_user, benefit_type: params[:benefit_type]) + .body + render json: merge_legacy_appeals(ci) + rescue => e + log_exception_to_personal_information_log( + e, + error_class: "#{self.class.name}#index exception #{e.class} (HLR_V1)", + benefit_type: params[:benefit_type] + ) + raise + end + + private + + def merge_legacy_appeals(contestable_issues) + # Fetch Legacy Appels and combine with CIs + ci_la = nil + begin + la = decision_review_service + .get_legacy_appeals(user: current_user) + .body + # punch in an empty LA section if no LAs for user to distinguish no LAs from a LA call fail + la['data'] = [{ type: 'legacyAppeal', attributes: { issues: [] } }] if la['data'].empty? + ci_la = { data: contestable_issues['data'] + la['data'] } + rescue => e + # If LA fails keep going Legacy Appeals are not critical, return original contestable_issues + log_exception_to_personal_information_log( + e, + error_class: "#{self.class.name}#index exception #{e.class} (HLR_V1_LEGACY_APPEALS)", + benefit_type: params[:benefit_type] + ) + contestable_issues + else + ci_la + end + end + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/higher_level_reviews_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/higher_level_reviews_controller.rb new file mode 100644 index 00000000000..dd90ec7e5c8 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/higher_level_reviews_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'decision_reviews/saved_claim/service' + +module DecisionReviews + module V1 + class HigherLevelReviewsController < AppealsBaseController + include DecisionReviews::SavedClaim::Service + service_tag 'higher-level-review' + + def show + render json: decision_review_service.get_higher_level_review(params[:id]).body + rescue => e + log_exception_to_personal_information_log( + e, error_class: error_class(method: 'show', exception_class: e.class), id: params[:id] + ) + raise + end + + def create + hlr_response_body = decision_review_service + .create_higher_level_review(request_body: request_body_hash, user: @current_user) + .body + submitted_appeal_uuid = hlr_response_body.dig('data', 'id') + ActiveRecord::Base.transaction do + AppealSubmission.create!(user_uuid: @current_user.uuid, user_account: @current_user.user_account, + type_of_appeal: 'HLR', submitted_appeal_uuid:) + + store_saved_claim(claim_class: ::SavedClaim::HigherLevelReview, form: request_body_hash.to_json, + guid: submitted_appeal_uuid) + + # Clear in-progress form since submit was successful + InProgressForm.form_for_user('20-0996', current_user)&.destroy! + end + render json: hlr_response_body + rescue => e + ::Rails.logger.error( + message: "Exception occurred while submitting Higher Level Review: #{e.message}", + backtrace: e.backtrace + ) + + handle_personal_info_error(e) + end + + private + + def error_class(method:, exception_class:) + "#{self.class.name}##{method} exception #{exception_class} (HLR_V1)" + end + + def handle_personal_info_error(e) + request = begin + { body: request_body_hash } + rescue + request_body_debug_data + end + + log_exception_to_personal_information_log( + e, error_class: error_class(method: 'create', exception_class: e.class), request: + ) + raise + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/notice_of_disagreements/contestable_issues_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/notice_of_disagreements/contestable_issues_controller.rb new file mode 100644 index 00000000000..ca4bc08cdfb --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/notice_of_disagreements/contestable_issues_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module DecisionReviews + module V1 + module NoticeOfDisagreements + class ContestableIssuesController < AppealsBaseController + service_tag 'board-appeal' + + def index + render json: decision_review_service + .get_notice_of_disagreement_contestable_issues(user: current_user) + .body + rescue => e + log_exception_to_personal_information_log e, + error_class: + "#{self.class.name}#index exception #{e.class} (NOD_V1)" + raise + end + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/notice_of_disagreements_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/notice_of_disagreements_controller.rb new file mode 100644 index 00000000000..54e15439b39 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/notice_of_disagreements_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module DecisionReviews + module V1 + class NoticeOfDisagreementsController < AppealsBaseController + service_tag 'board-appeal' + + def show + render json: decision_review_service.get_notice_of_disagreement(params[:id]).body + rescue => e + log_exception_to_personal_information_log( + e, error_class: error_class(method: 'show', exception_class: e.class), id: params[:id] + ) + raise + end + + def create + nod_response_body = AppealSubmission.submit_nod( + current_user: @current_user, + request_body_hash:, + decision_review_service: + ) + + render json: nod_response_body + rescue => e + ::Rails.logger.error( + message: "Exception occurred while submitting Notice Of Disagreement: #{e.message}", + backtrace: e.backtrace + ) + handle_personal_info_error(e) + end + + private + + def error_class(method:, exception_class:) + "#{self.class.name}##{method} exception #{exception_class} (NOD_V1)" + end + + def handle_personal_info_error(e) + request = begin + { body: request_body_hash } + rescue + request_body_debug_data + end + + log_exception_to_personal_information_log( + e, error_class: error_class(method: 'create', exception_class: e.class), request: + ) + raise + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/supplemental_claims/contestable_issues_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/supplemental_claims/contestable_issues_controller.rb new file mode 100644 index 00000000000..d7bb4f35759 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/supplemental_claims/contestable_issues_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module DecisionReviews + module V1 + module SupplementalClaims + class ContestableIssuesController < AppealsBaseController + service_tag 'appeal-application' + + def index + ci = decision_review_service + .get_supplemental_claim_contestable_issues(user: current_user, benefit_type: params[:benefit_type]) + .body + render json: merge_legacy_appeals(ci) + rescue => e + log_exception_to_personal_information_log e, + error_class: "#{self.class.name}#index exception #{e.class} (SC_V1)" + raise + end + + def merge_legacy_appeals(contestable_issues) + # Fetch Legacy Appels and combine with CIs + ci_la = nil + begin + la = decision_review_service + .get_legacy_appeals(user: current_user) + .body + # punch in an empty LA section if no LAs for user to distinguish no LAs from a LA call fail + la['data'] = [{ type: 'legacyAppeal', attributes: { issues: [] } }] if la['data'].empty? + ci_la = { data: contestable_issues['data'] + la['data'] } + rescue => e + # If LA fails keep going Legacy Appeals are not critical, return original contestable_issues + log_exception_to_personal_information_log( + e, + error_class: "#{self.class.name}#index exception #{e.class} (SC_V1_LEGACY_APPEALS)", + benefit_type: params[:benefit_type] + ) + contestable_issues + else + ci_la + end + end + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v1/supplemental_claims_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v1/supplemental_claims_controller.rb new file mode 100644 index 00000000000..c1f96cd3e12 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v1/supplemental_claims_controller.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'decision_reviews/v1//constants' +require 'decision_reviews/v1/helpers' +require 'decision_reviews/saved_claim/service' +module DecisionReviews + module V1 + class SupplementalClaimsController < AppealsBaseController + include DecisionReviews::V1::Helpers + include DecisionReviews::SavedClaim::Service + service_tag 'appeal-application' + + def show + render json: decision_review_service.get_supplemental_claim(params[:id]).body + rescue => e + log_exception_to_personal_information_log( + e, error_class: error_class(method: 'show', exception_class: e.class), id: params[:id] + ) + raise + end + + def create + process_submission + rescue => e + ::Rails.logger.error( + message: "Exception occurred while submitting Supplemental Claim: #{e.message}", + backtrace: e.backtrace + ) + handle_personal_info_error(e) + end + + private + + def post_create_log_msg(appeal_submission_id:, submitted_appeal_uuid:) + { + message: 'Supplemental Claim Appeal Record Created', + appeal_submission_id:, + lighthouse_submission: { + id: submitted_appeal_uuid + } + } + end + + def handle_4142(request_body:, form4142:, appeal_submission_id:, submitted_appeal_uuid:) # rubocop:disable Naming/VariableNumber + return if form4142.blank? + + rejiggered_payload = get_and_rejigger_required_info(request_body:, form4142:, user: @current_user) + jid = decision_review_service.queue_form4142(appeal_submission_id:, rejiggered_payload:, submitted_appeal_uuid:) + log_form4142_job_queued(appeal_submission_id, submitted_appeal_uuid, jid) + end + + def log_form4142_job_queued(appeal_submission_id, submitted_appeal_uuid, jid) + ::Rails.logger.info({ + form_id: DecisionReviews::V1::FORM4142_ID, + parent_form_id: DecisionReviews::V1::SUPP_CLAIM_FORM_ID, + message: 'Supplemental Claim Form4142 queued.', + jid:, + appeal_submission_id:, + lighthouse_submission: { + id: submitted_appeal_uuid + } + }) + end + + def submit_evidence(sc_evidence, appeal_submission_id, submitted_appeal_uuid) + # I know I could just use `appeal_submission.enqueue_uploads` here, but I want to return the jids to log, so + # replicating instead. There is some duplicate code but I want them jids in the logs. + jids = decision_review_service.queue_submit_evidence_uploads(sc_evidence, appeal_submission_id) + ::Rails.logger.info({ + form_id: DecisionReviews::V1::SUPP_CLAIM_FORM_ID, + message: 'Supplemental Claim Evidence jobs created.', + appeal_submission_id:, + lighthouse_submission: { + id: submitted_appeal_uuid + }, + evidence_upload_job_ids: jids + }) + end + + def handle_personal_info_error(e) + request = begin + { body: request_body_hash } + rescue + request_body_debug_data + end + log_exception_to_personal_information_log( + e, error_class: error_class(method: 'create', exception_class: e.class), request: + ) + raise + end + + def process_submission + req_body_obj = request_body_hash.is_a?(String) ? JSON.parse(request_body_hash) : request_body_hash + saved_claim_request_body = req_body_obj.to_json # serialize before request body is modified + form4142 = req_body_obj.delete('form4142') + sc_evidence = req_body_obj.delete('additionalDocuments') + zip_from_frontend = req_body_obj.dig('data', 'attributes', 'veteran', 'address', 'zipCode5') + + sc_response = decision_review_service.create_supplemental_claim(request_body: req_body_obj, user: @current_user) + submitted_appeal_uuid = sc_response.body.dig('data', 'id') + + ActiveRecord::Base.transaction do + appeal_submission_id = create_appeal_submission(submitted_appeal_uuid, zip_from_frontend) + handle_saved_claim(form: saved_claim_request_body, guid: submitted_appeal_uuid, form4142:) + + ::Rails.logger.info(post_create_log_msg(appeal_submission_id:, submitted_appeal_uuid:)) + handle_4142(request_body: req_body_obj, form4142:, appeal_submission_id:, submitted_appeal_uuid:) + submit_evidence(sc_evidence, appeal_submission_id, submitted_appeal_uuid) if sc_evidence.present? + + # Only destroy InProgressForm after evidence upload step + # so that we still have references if a fatal error occurs before this step + clear_in_progress_form + end + render json: sc_response.body, status: sc_response.status + end + + def create_appeal_submission(submitted_appeal_uuid, backup_zip) + upload_metadata = DecisionReviews::V1::Service.file_upload_metadata( + @current_user, backup_zip + ) + create_params = { + user_uuid: @current_user.uuid, + user_account: @current_user.user_account, + type_of_appeal: 'SC', + submitted_appeal_uuid:, + upload_metadata: + } + appeal_submission = AppealSubmission.create!(create_params) + appeal_submission.id + end + + def handle_saved_claim(form:, guid:, form4142:) + uploaded_forms = [] + uploaded_forms << '21-4142' if form4142.present? + store_saved_claim(claim_class: ::SavedClaim::SupplementalClaim, form:, guid:, uploaded_forms:) + end + + def clear_in_progress_form + InProgressForm.form_for_user('20-0995', @current_user)&.destroy! + end + + def error_class(method:, exception_class:) + "#{self.class.name}##{method} exception #{exception_class} (SC_V1)" + end + end + end +end diff --git a/modules/decision_reviews/app/controllers/decision_reviews/v2/higher_level_reviews_controller.rb b/modules/decision_reviews/app/controllers/decision_reviews/v2/higher_level_reviews_controller.rb new file mode 100644 index 00000000000..b62a7bc6824 --- /dev/null +++ b/modules/decision_reviews/app/controllers/decision_reviews/v2/higher_level_reviews_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'decision_reviews/saved_claim/service' +require_relative '../v1/appeals_base_controller' + +module DecisionReviews + module V2 + class HigherLevelReviewsController < V1::AppealsBaseController + include DecisionReviews::SavedClaim::Service + service_tag 'higher-level-review' + + def show + render json: decision_review_service.get_higher_level_review(params[:id]).body + rescue => e + log_exception_to_personal_information_log( + e, error_class: error_class(method: 'show', exception_class: e.class), id: params[:id] + ) + raise + end + + def create + hlr_response_body = decision_review_service + .create_higher_level_review(request_body: request_body_hash, user: @current_user, + version: 'V2') + .body + submitted_appeal_uuid = hlr_response_body.dig('data', 'id') + ActiveRecord::Base.transaction do + AppealSubmission.create!(user_uuid: @current_user.uuid, user_account: @current_user.user_account, + type_of_appeal: 'HLR', submitted_appeal_uuid:) + + store_saved_claim(claim_class: ::SavedClaim::HigherLevelReview, form: request_body_hash.to_json, + guid: submitted_appeal_uuid) + + # Clear in-progress form since submit was successful + InProgressForm.form_for_user('20-0996', current_user)&.destroy! + end + render json: hlr_response_body + rescue => e + ::Rails.logger.error( + message: "Exception occurred while submitting Higher Level Review: #{e.message}", + backtrace: e.backtrace + ) + + handle_personal_info_error(e) + end + + private + + def error_class(method:, exception_class:) + "#{self.class.name}##{method} exception #{exception_class} (HLR_V2)" + end + + def handle_personal_info_error(e) + request = begin + { body: request_body_hash } + rescue + request_body_debug_data + end + + log_exception_to_personal_information_log( + e, error_class: error_class(method: 'create', exception_class: e.class), request: + ) + raise + end + end + end +end diff --git a/modules/decision_reviews/config/routes.rb b/modules/decision_reviews/config/routes.rb index bdb298e5939..e46de01acd1 100644 --- a/modules/decision_reviews/config/routes.rb +++ b/modules/decision_reviews/config/routes.rb @@ -1,4 +1,30 @@ # frozen_string_literal: true DecisionReviews::Engine.routes.draw do + namespace :v1, defaults: { format: 'json' } do + namespace :higher_level_reviews do + get 'contestable_issues(/:benefit_type)', to: 'contestable_issues#index' + end + resources :higher_level_reviews, only: %i[create show] + + namespace :notice_of_disagreements do + get 'contestable_issues', to: 'contestable_issues#index' + end + resources :notice_of_disagreements, only: %i[create show] + + namespace :supplemental_claims do + get 'contestable_issues(/:benefit_type)', to: 'contestable_issues#index' + end + resources :supplemental_claims, only: %i[create show] + + resource :decision_review_evidence, only: :create + + scope format: false do + resources :nod_callbacks, only: [:create], controller: :decision_review_notification_callbacks + end + end + + namespace :v2, defaults: { format: 'json' } do + resources :higher_level_reviews, only: %i[create show] + end end diff --git a/modules/decision_reviews/lib/decision_reviews/saved_claim/service.rb b/modules/decision_reviews/lib/decision_reviews/saved_claim/service.rb new file mode 100644 index 00000000000..ddc4f8c57de --- /dev/null +++ b/modules/decision_reviews/lib/decision_reviews/saved_claim/service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DecisionReviews + ## + # Service for persisting Decision Review SavedClaim + # + module SavedClaim + module Service + VALID_CLASS = [ + ::SavedClaim::HigherLevelReview, + ::SavedClaim::NoticeOfDisagreement, + ::SavedClaim::SupplementalClaim + ].freeze + + def store_saved_claim(claim_class:, form:, guid:, uploaded_forms: []) + raise "Invalid class type '#{claim_class}'" unless VALID_CLASS.include? claim_class + + claim = claim_class.new(form:, guid:, uploaded_forms:) + claim.save! + end + end + end +end diff --git a/modules/decision_reviews/lib/decision_reviews/v1/service.rb b/modules/decision_reviews/lib/decision_reviews/v1/service.rb index 32ad4fc4f6e..0a85f75a79d 100644 --- a/modules/decision_reviews/lib/decision_reviews/v1/service.rb +++ b/modules/decision_reviews/lib/decision_reviews/v1/service.rb @@ -36,11 +36,11 @@ class Service < Common::Client::Base # @param user [User] Veteran who the form is in regard to # @return [Faraday::Response] # - def create_higher_level_review(request_body:, user:) + def create_higher_level_review(request_body:, user:, version: 'V1') with_monitoring_and_error_handling do headers = create_higher_level_review_headers(user) common_log_params = { key: :overall_claim_submission, form_id: '996', user_uuid: user.uuid, - downstream_system: 'Lighthouse' } + downstream_system: 'Lighthouse', params: { version: } } begin response = perform :post, 'higher_level_reviews', request_body, headers log_formatted(**common_log_params.merge(is_success: true, status_code: response.status, @@ -51,7 +51,7 @@ def create_higher_level_review(request_body:, user:) end raise_schema_error_unless_200_status response.status validate_against_schema json: response.body, schema: HLR_CREATE_RESPONSE_SCHEMA, - append_to_error_class: ' (HLR_V1)' + append_to_error_class: " (HLR_#{version}})" response end end diff --git a/modules/decision_reviews/spec/controllers/decision_review_evidences_controller_spec.rb b/modules/decision_reviews/spec/controllers/decision_review_evidences_controller_spec.rb new file mode 100644 index 00000000000..238fdbfbdb0 --- /dev/null +++ b/modules/decision_reviews/spec/controllers/decision_review_evidences_controller_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DecisionReviews::V1::DecisionReviewEvidencesController, type: :controller do + routes { DecisionReviews::Engine.routes } + + describe '::FORM_ATTACHMENT_MODEL' do + it 'is a FormAttachment model' do + expect(described_class::FORM_ATTACHMENT_MODEL.ancestors).to include(FormAttachment) + end + end + + describe '#create' do + let(:form_attachment_guid) { SecureRandom.uuid } + let(:pdf_file) do + fixture_file_upload('doctors-note.pdf', 'application/pdf') + end + let(:form_attachment_model) { described_class::FORM_ATTACHMENT_MODEL } + let(:param_namespace) { form_attachment_model.to_s.underscore.split('/').last } + let(:resource_name) { form_attachment_model.name.remove('::').snakecase } + let(:json_api_type) { resource_name.pluralize } + let(:attachment_factory_id) { resource_name.to_sym } + let(:user) { build(:user, :loa1) } + + before do + sign_in_as(user) + end + + it 'requires params.`param_namespace`' do + empty_req_params = [nil, {}] + empty_req_params << { param_namespace => {} } + empty_req_params.each do |params| + post(:create, params:) + + expect(response).to have_http_status(:bad_request) + + response_body = JSON.parse(response.body) + + expect(response_body['errors'].size).to eq(1) + expect(response_body['errors'][0]).to eq( + 'title' => 'Missing parameter', + 'detail' => "The required parameter \"#{param_namespace}\", is missing", + 'code' => '108', + 'status' => '400' + ) + end + end + + it 'requires file_data to be a file' do + params = { param_namespace => { file_data: 'not_a_file_just_a_string' } } + post(:create, params:) + expect(response).to have_http_status(:bad_request) + response_body_errors = JSON.parse(response.body)['errors'] + + expect(response_body_errors.size).to eq(1) + expect(response_body_errors[0]).to eq( + 'title' => 'Invalid field value', + 'detail' => '"String" is not a valid value for "file_data"', + 'code' => '103', + 'status' => '400' + ) + end + + context 'with a param password' do + let(:encrypted_log_params_success) do + { + message: 'Evidence upload to s3 success!', + user_uuid: user.uuid, + action: 'Evidence upload to s3', + form_id: '10182', + upstream_system: nil, + downstream_system: 'AWS S3', + is_success: true, + http: { + status_code: nil, + body: nil + }, + form_attachment_guid:, + encrypted: true + } + end + + let(:expected_response_body) do + { + 'data' => { + 'id' => '99', + 'type' => json_api_type, + 'attributes' => { + 'guid' => form_attachment_guid + } + } + } + end + + it 'creates a FormAttachment, logs formatted success message, and increments statsd' do + request.env['SOURCE_APP'] = '10182-board-appeal' + params = { param_namespace => { file_data: pdf_file, password: 'test_password' } } + + expect(Common::PdfHelpers).to receive(:unlock_pdf) + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(encrypted_log_params_success) + expect(StatsD).to receive(:increment).with('decision_review.form_10182.evidence_upload_to_s3.success') + form_attachment = build(attachment_factory_id, guid: form_attachment_guid) + + expect(form_attachment_model).to receive(:new) do + expect(form_attachment).to receive(:set_file_data!) + + expect(form_attachment).to receive(:save!) do + form_attachment.id = 99 + form_attachment + end + + form_attachment + end + + post(:create, params:) + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq(expected_response_body) + end + end + + context 'evidence is uploaded from the NOD (10182) form' do + it 'formatted success log and statsd metric are specific to NOD (10182)' do + request.env['SOURCE_APP'] = '10182-board-appeal' + params = { param_namespace => { file_data: pdf_file } } + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(hash_including(form_id: '10182')) + expect(StatsD).to receive(:increment).with('decision_review.form_10182.evidence_upload_to_s3.success') + post(:create, params:) + end + end + + context 'evidence is uploaded from the SC (995) form' do + it 'formatted success log and statsd metric are specific to SC (995)' do + request.env['SOURCE_APP'] = '995-supplemental-claim' + params = { param_namespace => { file_data: pdf_file } } + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(hash_including(form_id: '995')) + expect(StatsD).to receive(:increment).with('decision_review.form_995.evidence_upload_to_s3.success') + post(:create, params:) + end + end + + context 'evidence is uploaded from a form with an unexpected Source-App-Name' do + it 'logs formatted success log and increments success statsd metric, but also increments an `unexpected_form_id` statsd metric' do # rubocop:disable Layout/LineLength + request.env['SOURCE_APP'] = '997-supplemental-claim' + params = { param_namespace => { file_data: pdf_file } } + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(hash_including(form_id: '997-supplemental-claim')) + expect(StatsD).to receive(:increment).with('decision_review.form_997-supplemental-claim.evidence_upload_to_s3.success') # rubocop:disable Layout/LineLength + expect(StatsD).to receive(:increment).with('decision_review.evidence_upload_to_s3.unexpected_form_id') + post(:create, params:) + end + end + + context 'an error is thrown during file upload' do + it 'logs formatted error, increments statsd, and raises error' do + request.env['SOURCE_APP'] = '10182-board-appeal' + params = { param_namespace => { file_data: pdf_file } } + expect(StatsD).to receive(:increment).with('decision_review.form_10182.evidence_upload_to_s3.failure') + allow(Rails.logger).to receive(:error) + expect(Rails.logger).to receive(:error).with({ + message: 'Evidence upload to s3 failure!', + user_uuid: user.uuid, + action: 'Evidence upload to s3', + form_id: '10182', + upstream_system: nil, + downstream_system: 'AWS S3', + is_success: false, + http: { + status_code: 422, + body: 'Unprocessable Entity' + }, + form_attachment_guid:, + encrypted: false + }) + form_attachment = build(attachment_factory_id, guid: form_attachment_guid) + expect(form_attachment_model).to receive(:new).and_return(form_attachment) + expected_error = Common::Exceptions::UnprocessableEntity.new( + detail: 'Test Error!', + source: 'FormAttachment.set_file_data' + ) + expect(form_attachment).to receive(:set_file_data!).and_raise(expected_error) + post(:create, params:) + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)).to eq( + { + 'errors' => [{ + 'title' => 'Unprocessable Entity', + 'detail' => 'Test Error!', + 'code' => '422', + 'source' => 'FormAttachment.set_file_data', + 'status' => '422' + }] + } + ) + end + end + end +end diff --git a/modules/decision_reviews/spec/controllers/decision_review_notification_callbacks_controller_spec.rb b/modules/decision_reviews/spec/controllers/decision_review_notification_callbacks_controller_spec.rb new file mode 100644 index 00000000000..a0373d0682d --- /dev/null +++ b/modules/decision_reviews/spec/controllers/decision_review_notification_callbacks_controller_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DecisionReviews::V1::DecisionReviewNotificationCallbacksController, type: :controller do + routes { DecisionReviews::Engine.routes } + + let(:notification_id) { SecureRandom.uuid } + let(:reference) { "NOD-form-#{SecureRandom.uuid}" } + let(:status) { 'delivered' } + let(:params) do + { + id: notification_id, + reference:, + to: 'test@test.com', + status:, + created_at: '2023-01-10T00:04:25.273410Z', + completed_at: '2023-01-10T00:05:33.255911Z', + sent_at: '2023-01-10T00:04:25.775363Z', + notification_type: 'email', + status_reason: '', + provider: 'sendgrid' + }.stringify_keys! + end + + describe '#create' do + before do + request.headers['Authorization'] = "Bearer #{Settings.nod_vanotify_status_callback.bearer_token}" + Flipper.enable(:nod_callbacks_endpoint) + + allow(DecisionReviewNotificationAuditLog).to receive(:create!) + end + + context 'the record saved without an issue' do + it 'returns success' do + expect(DecisionReviewNotificationAuditLog).to receive(:create!) + .with(notification_id:, reference:, status:, payload: params) + + post(:create, params:, as: :json) + + expect(response).to have_http_status(:ok) + + res = JSON.parse(response.body) + expect(res['message']).to eq 'success' + end + end + + context 'the record failed to save' do + before do + expect(DecisionReviewNotificationAuditLog).to receive(:create!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'returns failed' do + post(:create, params:, as: :json) + + expect(response).to have_http_status(:ok) + + res = JSON.parse(response.body) + expect(res['message']).to eq 'failed' + end + end + + context 'the reference value is formatted correctly' do + let(:tags) { ['service:board-appeal', 'function: form submission to Lighthouse'] } + + before do + allow(StatsD).to receive(:increment) + allow(Rails.logger).to receive(:error) + end + + it 'sends a silent_failure_avoided statsd metric' do + expect(StatsD).to receive(:increment).with('silent_failure_avoided', tags:) + expect(Rails.logger).not_to receive(:error) + + post(:create, params:, as: :json) + end + + context 'when the reference is for a secondary form' do + let(:reference) { "SC-secondary_form-#{SecureRandom.uuid}" } + let(:tags) { ['service:supplemental-claims', 'function: secondary_form submission to Lighthouse'] } + + it 'sends a silent_failure_avoided statsd metric' do + expect(StatsD).to receive(:increment).with('silent_failure_avoided', tags:) + expect(Rails.logger).not_to receive(:error) + + post(:create, params:, as: :json) + end + end + end + + context 'the reference appeal_type is invalid' do + let(:reference) { 'APPEALTYPE-form-submitted-appeal-uuid' } + let(:logged_params) { { reference:, message: 'key not found: "APPEALTYPE"' } } + + before do + allow(StatsD).to receive(:increment) + allow(Rails.logger).to receive(:error) + end + + it 'logs an error and does not send a silent_failure_avoided statsd metric' do + expect(StatsD).not_to receive(:increment).with('silent_failure_avoided', tags: anything) + expect(Rails.logger).to receive(:error).with('Failed to send silent_failure_avoided metric', + params: logged_params) + + post(:create, params:, as: :json) + end + end + + context 'the reference function_type is invalid' do + let(:reference) { 'HLR-function_type-submitted-appeal-uuid' } + let(:logged_params) { { reference:, message: 'Invalid function_type' } } + + before do + allow(StatsD).to receive(:increment) + allow(Rails.logger).to receive(:error) + end + + it 'logs an error and does not send a silent_failure_avoided statsd metric' do + expect(StatsD).not_to receive(:increment).with('silent_failure_avoided', tags: anything) + expect(Rails.logger).to receive(:error).with('Failed to send silent_failure_avoided metric', + params: logged_params) + + post(:create, params:, as: :json) + end + end + end + + describe 'authentication' do + context 'with missing Authorization header' do + it 'returns 401' do + request.headers['Authorization'] = nil + post(:create, params:, as: :json) + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with invalid Authorization header' do + it 'returns 401' do + request.headers['Authorization'] = 'Bearer foo' + post(:create, params:, as: :json) + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'feature flag is disabled' do + before do + Flipper.disable :nod_callbacks_endpoint + end + + it 'returns a 404 error code' do + request.headers['Authorization'] = "Bearer #{Settings.nod_vanotify_status_callback.bearer_token}" + post(:create, params:, as: :json) + + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/modules/decision_reviews/spec/requests/v1/higher_level_reviews/contestable_issues_spec.rb b/modules/decision_reviews/spec/requests/v1/higher_level_reviews/contestable_issues_spec.rb new file mode 100644 index 00000000000..24796d0f20a --- /dev/null +++ b/modules/decision_reviews/spec/requests/v1/higher_level_reviews/contestable_issues_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/controller_spec_helper' + +RSpec.describe 'DecisionReviews::V1::HigherLevelReviews::ContestableIssues', type: :request do + let(:user) { build(:user, :loa3) } + let(:success_log_args) do + { + message: 'Get contestable issues success!', + user_uuid: user.uuid, + action: 'Get contestable issues', + form_id: '996', + upstream_system: 'Lighthouse', + downstream_system: nil, + is_success: true, + http: { + status_code: 200, + body: '[Redacted]' + } + } + end + let(:error_log_args) do + { + message: 'Get contestable issues failure!', + user_uuid: user.uuid, + action: 'Get contestable issues', + form_id: '996', + upstream_system: 'Lighthouse', + downstream_system: nil, + is_success: false, + http: { + status_code: 404, + body: anything + } + } + end + + before { sign_in_as(user) } + + describe '#index' do + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V1::HigherLevelReviews::ContestableIssuesController#index exception % (HLR_V1)' # rubocop:disable Layout/LineLength + end + + subject { get '/decision_reviews/v1/higher_level_reviews/contestable_issues/compensation' } + + it 'fetches issues that the Veteran could contest via a higher-level review' do + VCR.use_cassette('decision_review/HLR-GET-CONTESTABLE-ISSUES-RESPONSE-200_V1') do + VCR.use_cassette('decision_review/HLR-GET-LEGACY_APPEALS-RESPONSE-200_V1') do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(success_log_args) + subject + expect(response).to be_successful + expect(JSON.parse(response.body)['data']).to be_an Array + expect(JSON.parse(response.body)['data'].length).to be 4 + end + end + end + + it 'fetches issues that the Veteran could contest via a higher-level review, but empty Legacy Appeals' do + VCR.use_cassette('decision_review/HLR-GET-CONTESTABLE-ISSUES-RESPONSE-200_V1') do + VCR.use_cassette('decision_review/HLR-GET-LEGACY_APPEALS-RESPONSE-200-EMPTY_V1') do + subject + expect(response).to be_successful + expect(JSON.parse(response.body)['data']).to be_an Array + expect(JSON.parse(response.body)['data'].length).to be 4 + end + end + end + + it 'adds to the PersonalInformationLog when an exception is thrown' do + VCR.use_cassette('decision_review/HLR-GET-CONTESTABLE-ISSUES-RESPONSE-404_V1') do + expect(personal_information_logs.count).to be 0 + allow(Rails.logger).to receive(:error) + expect(Rails.logger).to receive(:error).with(error_log_args) + subject + expect(personal_information_logs.count).to be 1 + pil = personal_information_logs.first + expect(pil.data['user']).to be_truthy + expect(pil.data['error']).to be_truthy + end + end + end +end diff --git a/modules/decision_reviews/spec/requests/v1/higher_level_reviews_spec.rb b/modules/decision_reviews/spec/requests/v1/higher_level_reviews_spec.rb new file mode 100644 index 00000000000..5dac7bb11ae --- /dev/null +++ b/modules/decision_reviews/spec/requests/v1/higher_level_reviews_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/controller_spec_helper' + +RSpec.describe 'DecisonReviews::V1::HigherLevelReviews', type: :request do + let(:user) { build(:user, :loa3) } + let(:headers) { { 'CONTENT_TYPE' => 'application/json' } } + let(:success_log_args) do + { + message: 'Overall claim submission success!', + user_uuid: user.uuid, + action: 'Overall claim submission', + form_id: '996', + upstream_system: nil, + downstream_system: 'Lighthouse', + is_success: true, + http: { + status_code: 200, + body: '[Redacted]' + }, + version: 'V1' + } + end + let(:error_log_args) do + { + message: 'Overall claim submission failure!', + user_uuid: user.uuid, + action: 'Overall claim submission', + form_id: '996', + upstream_system: nil, + downstream_system: 'Lighthouse', + is_success: false, + http: { + status_code: 422, + body: response_error_body + }, + version: 'V1' + } + end + + let(:response_error_body) do + { + 'errors' => [{ 'title' => 'Missing required fields', + 'detail' => 'One or more expected fields were not found', + 'code' => '145', + 'source' => { 'pointer' => '/' }, + 'status' => '422', + 'meta' => { 'missing_fields' => %w[data included] } }] + } + end + + let(:extra_error_log_message) do + 'BackendServiceException: {:source=>"Common::Client::Errors::ClientError raised in DecisionReviews::V1::Service", :code=>"DR_422"}' # rubocop:disable Layout/LineLength + end + + before { sign_in_as(user) } + + describe '#create' do + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V1::HigherLevelReviewsController#create exception % (HLR_V1)' + end + + subject do + post '/decision_reviews/v1/higher_level_reviews', + params: VetsJsonSchema::EXAMPLES.fetch('HLR-CREATE-REQUEST-BODY_V1').to_json, + headers: + end + + it 'creates an HLR' do + VCR.use_cassette('decision_review/HLR-CREATE-RESPONSE-200_V1') do + # Create an InProgressForm + in_progress_form = create(:in_progress_form, user_uuid: user.uuid, form_id: '20-0996') + expect(in_progress_form).not_to be_nil + + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(success_log_args) + allow(StatsD).to receive(:increment) + expect(StatsD).to receive(:increment).with('decision_review.form_996.overall_claim_submission.success') + + subject + expect(response).to be_successful + appeal_uuid = JSON.parse(response.body)['data']['id'] + expect(AppealSubmission.where(submitted_appeal_uuid: appeal_uuid).first).to be_truthy + # InProgressForm should be destroyed after successful submission + in_progress_form = InProgressForm.find_by(user_uuid: user.uuid, form_id: '20-0996') + expect(in_progress_form).to be_nil + # SavedClaim should be created with request data + saved_claim = SavedClaim::HigherLevelReview.find_by(guid: appeal_uuid) + expect(saved_claim.form).to eq(VetsJsonSchema::EXAMPLES.fetch('HLR-CREATE-REQUEST-BODY_V1').to_json) + end + end + + context 'when an error occurs with the api call' do + it 'adds to the PersonalInformationLog' do + VCR.use_cassette('decision_review/HLR-CREATE-RESPONSE-422_V1') do + expect(personal_information_logs.count).to be 0 + + allow(Rails.logger).to receive(:error) + expect(Rails.logger).to receive(:error).with(error_log_args) + expect(Rails.logger).to receive(:error).with( + message: "Exception occurred while submitting Higher Level Review: #{extra_error_log_message}", + backtrace: anything + ) + expect(Rails.logger).to receive(:error).with(extra_error_log_message, anything) + allow(StatsD).to receive(:increment) + expect(StatsD).to receive(:increment).with('decision_review.form_996.overall_claim_submission.failure') + + subject + expect(personal_information_logs.count).to be 1 + pil = personal_information_logs.first + %w[ + first_name last_name birls_id icn edipi mhv_correlation_id + participant_id vet360_id ssn assurance_level birth_date + ].each { |key| expect(pil.data['user'][key]).to be_truthy } + %w[message backtrace key response_values original_status original_body] + .each { |key| expect(pil.data['error'][key]).to be_truthy } + expect(pil.data['additional_data']['request']['body']).not_to be_empty + end + end + end + + context 'when an error occurs in the transaction' do + shared_examples 'rolledback transaction' do |model| + before do + allow_any_instance_of(model).to receive(:save!).and_raise(ActiveModel::Error) # stub a model error + end + + it 'rollsback transaction' do + VCR.use_cassette('decision_review/HLR-CREATE-RESPONSE-200_V1') do + expect(subject).to eq 500 + + # check that transaction rolled back / records were not persisted + expect(AppealSubmission.count).to eq 0 + expect(SavedClaim.count).to eq 0 + end + end + end + + context 'for AppealSubmission' do + it_behaves_like 'rolledback transaction', AppealSubmission + end + + context 'for SavedClaim' do + it_behaves_like 'rolledback transaction', SavedClaim + end + end + end +end diff --git a/modules/decision_reviews/spec/requests/v1/notice_of_disagreements/contestable_issues_spec.rb b/modules/decision_reviews/spec/requests/v1/notice_of_disagreements/contestable_issues_spec.rb new file mode 100644 index 00000000000..8b1ab534841 --- /dev/null +++ b/modules/decision_reviews/spec/requests/v1/notice_of_disagreements/contestable_issues_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/controller_spec_helper' + +RSpec.describe 'DecisionReviews::V1::NoticeOfDisagreements::ContestableIssues', type: :request do + let(:user) { build(:user, :loa3) } + + before { sign_in_as(user) } + + describe '#index' do + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V1::NoticeOfDisagreements::ContestableIssuesController#index exception % (NOD_V1)' # rubocop:disable Layout/LineLength + end + + subject { get '/decision_reviews/v1/notice_of_disagreements/contestable_issues' } + + it 'fetches issues that the Veteran could contest via a notice of disagreement' do + VCR.use_cassette('decision_review/NOD-GET-CONTESTABLE-ISSUES-RESPONSE-200_V1') do + subject + expect(response).to be_successful + expect(JSON.parse(response.body)['data']).to be_an Array + end + end + + it 'adds to the PersonalInformationLog when an exception is thrown' do + VCR.use_cassette('decision_review/NOD-GET-CONTESTABLE-ISSUES-RESPONSE-404_V1') do + expect(personal_information_logs.count).to be 0 + subject + expect(personal_information_logs.count).to be 1 + pil = personal_information_logs.first + expect(pil.data['user']).to be_truthy + expect(pil.data['error']).to be_truthy + end + end + end +end diff --git a/modules/decision_reviews/spec/requests/v1/notice_of_disagreements_spec.rb b/modules/decision_reviews/spec/requests/v1/notice_of_disagreements_spec.rb new file mode 100644 index 00000000000..fc2f60a49fa --- /dev/null +++ b/modules/decision_reviews/spec/requests/v1/notice_of_disagreements_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/controller_spec_helper' + +RSpec.describe 'DecisionReviews::V1::NoticeOfDisagreements', type: :request do + let(:user) do + build(:user, + :loa3, + mhv_correlation_id: 'some-mhv_correlation_id', + birls_id: 'some-birls_id', + participant_id: 'some-participant_id', + vet360_id: 'some-vet360_id') + end + let(:headers) { { 'CONTENT_TYPE' => 'application/json' } } + + let(:error_log_args) do + { + message: 'Overall claim submission failure!', + user_uuid: user.uuid, + action: 'Overall claim submission', + form_id: '10182', + upstream_system: nil, + downstream_system: 'Lighthouse', + is_success: false, + http: { + status_code: 422, + body: response_error_body + } + } + end + + let(:response_error_body) do + { + 'errors' => [{ 'title' => 'Missing required fields', + 'detail' => 'One or more expected fields were not found', + 'code' => '145', + 'source' => { 'pointer' => '/data/attributes' }, + 'status' => '422', + 'meta' => { 'missing_fields' => ['boardReviewOption'] } }] + } + end + + before { sign_in_as(user) } + + describe '#create' do + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V1::NoticeOfDisagreementsController#create exception % (NOD_V1)' + end + + subject do + post '/decision_reviews/v1/notice_of_disagreements', + params: test_request_body.to_json, + headers: + end + + let(:extra_error_log_message) do + 'BackendServiceException: {:source=>"Common::Client::Errors::ClientError raised in DecisionReviews::V1::Service", :code=>"DR_422"}' # rubocop:disable Layout/LineLength + end + + let(:test_request_body) do + JSON.parse Rails.root.join('spec', 'fixtures', 'notice_of_disagreements', + 'valid_NOD_create_request.json').read + end + + it 'creates an NOD and logs to StatsD and logger' do + VCR.use_cassette('decision_review/NOD-CREATE-RESPONSE-200_V1') do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with({ + message: 'Overall claim submission success!', + user_uuid: user.uuid, + action: 'Overall claim submission', + form_id: '10182', + upstream_system: nil, + downstream_system: 'Lighthouse', + is_success: true, + http: { + status_code: 200, + body: '[Redacted]' + } + }) + allow(StatsD).to receive(:increment) + expect(StatsD).to receive(:increment).with('decision_review.form_10182.overall_claim_submission.success') + previous_appeal_submission_ids = AppealSubmission.all.pluck :submitted_appeal_uuid + # Create an InProgressForm + in_progress_form = create(:in_progress_form, user_uuid: user.uuid, form_id: '10182') + expect(in_progress_form).not_to be_nil + subject + expect(response).to be_successful + parsed_response = JSON.parse(response.body) + id = parsed_response['data']['id'] + expect(previous_appeal_submission_ids).not_to include id + appeal_submission = AppealSubmission.find_by(submitted_appeal_uuid: id) + expect(appeal_submission.type_of_appeal).to eq('NOD') + # AppealSubmissionUpload should be created for each form attachment + appeal_submission_uploads = AppealSubmissionUpload.where(appeal_submission:) + expect(appeal_submission_uploads.count).to eq 1 + # Evidence upload job should have been enqueued + expect(DecisionReview::SubmitUpload).to have_enqueued_sidekiq_job(appeal_submission_uploads.first.id) + # InProgressForm should be destroyed after successful submission + in_progress_form = InProgressForm.find_by(user_uuid: user.uuid, form_id: '10182') + expect(in_progress_form).to be_nil + # SavedClaim should be created with request data + saved_claim = SavedClaim::NoticeOfDisagreement.find_by(guid: id) + expect(JSON.parse(saved_claim.form)).to eq(test_request_body) + end + end + + it 'adds to the PersonalInformationLog when an exception is thrown and logs to StatsD and logger' do + VCR.use_cassette('decision_review/NOD-CREATE-RESPONSE-422_V1') do + allow(Rails.logger).to receive(:error) + expect(Rails.logger).to receive(:error).with(error_log_args) + expect(Rails.logger).to receive(:error).with( + message: "Exception occurred while submitting Notice Of Disagreement: #{extra_error_log_message}", + backtrace: anything + ) + expect(Rails.logger).to receive(:error).with(extra_error_log_message, anything) + allow(StatsD).to receive(:increment) + expect(StatsD).to receive(:increment).with('decision_review.form_10182.overall_claim_submission.failure') + expect(personal_information_logs.count).to be 0 + subject + expect(personal_information_logs.count).to be 1 + pil = personal_information_logs.first + %w[ + first_name last_name birls_id icn edipi mhv_correlation_id + participant_id vet360_id ssn assurance_level birth_date + ].each { |key| expect(pil.data['user'][key]).to be_truthy } + %w[message backtrace key response_values original_status original_body] + .each { |key| expect(pil.data['error'][key]).to be_truthy } + expect(pil.data['additional_data']['request']['body']).not_to be_empty + + # check that transaction rolled back / records were not persisted / evidence upload job was not queued up + expect(AppealSubmission.count).to eq 0 + expect(AppealSubmissionUpload.count).to eq 0 + expect(DecisionReview::SubmitUpload).not_to have_enqueued_sidekiq_job(anything) + + expect(SavedClaim.count).to eq 0 + end + end + + context 'when an error occurs in wrapped code' do + shared_examples 'rolledback transaction' do |model| + before do + allow_any_instance_of(model).to receive(:save!).and_raise(ActiveModel::Error) # stub a model error + end + + it 'rollsback transaction' do + VCR.use_cassette('decision_review/NOD-CREATE-RESPONSE-200_V1') do + expect(subject).to eq 500 + # check that transaction rolled back / records were not persisted / evidence upload job was not queued up + expect(AppealSubmission.count).to eq 0 + expect(AppealSubmissionUpload.count).to eq 0 + expect(DecisionReview::SubmitUpload).not_to have_enqueued_sidekiq_job(anything) + expect(SavedClaim.count).to eq 0 + end + end + end + + context 'for AppealSubmission' do + it_behaves_like 'rolledback transaction', AppealSubmission + end + + context 'for SavedClaim' do + it_behaves_like 'rolledback transaction', SavedClaim + end + end + end +end diff --git a/modules/decision_reviews/spec/requests/v1/supplemental_claims/contestable_issues_spec.rb b/modules/decision_reviews/spec/requests/v1/supplemental_claims/contestable_issues_spec.rb new file mode 100644 index 00000000000..b8459297f96 --- /dev/null +++ b/modules/decision_reviews/spec/requests/v1/supplemental_claims/contestable_issues_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/controller_spec_helper' + +RSpec.describe 'DecisionReviews::V1::SupplementalClaims::ContestableIssues', type: :request do + let(:user) { build(:user, :loa3) } + let(:success_log_args) do + { + message: 'Get contestable issues success!', + user_uuid: user.uuid, + action: 'Get contestable issues', + form_id: '995', + upstream_system: 'Lighthouse', + downstream_system: nil, + is_success: true, + http: { + status_code: 200, + body: '[Redacted]' + } + } + end + let(:error_log_args) do + { + message: 'Get contestable issues failure!', + user_uuid: user.uuid, + action: 'Get contestable issues', + form_id: '995', + upstream_system: 'Lighthouse', + downstream_system: nil, + is_success: false, + http: { + status_code: 404, + body: anything + } + } + end + + before { sign_in_as(user) } + + describe '#index' do + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V1::SupplementalClaims::ContestableIssuesController#index exception % (SC_V1)' # rubocop:disable Layout/LineLength + end + + subject { get '/decision_reviews/v1/supplemental_claims/contestable_issues/compensation' } + + it 'fetches issues that the Veteran could contest via a supplemental claim' do + VCR.use_cassette('decision_review/SC-GET-CONTESTABLE-ISSUES-RESPONSE-200_V1') do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(success_log_args) + subject + expect(response).to be_successful + expect(JSON.parse(response.body)['data']).to be_an Array + end + end + + it 'adds to the PersonalInformationLog when an exception is thrown' do + VCR.use_cassette('decision_review/SC-GET-CONTESTABLE-ISSUES-RESPONSE-404_V1') do + expect(personal_information_logs.count).to be 0 + allow(Rails.logger).to receive(:error) + expect(Rails.logger).to receive(:error).with(error_log_args) + subject + expect(personal_information_logs.count).to be 1 + pil = personal_information_logs.first + expect(pil.data['user']).to be_truthy + expect(pil.data['error']).to be_truthy + end + end + end +end diff --git a/modules/decision_reviews/spec/requests/v1/supplemental_claims_spec.rb b/modules/decision_reviews/spec/requests/v1/supplemental_claims_spec.rb new file mode 100644 index 00000000000..d82c6116dea --- /dev/null +++ b/modules/decision_reviews/spec/requests/v1/supplemental_claims_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/controller_spec_helper' + +RSpec.describe 'DecisionReviews::V1::SupplementalClaims', type: :request do + let(:user) { build(:user, :loa3) } + let(:headers) { { 'CONTENT_TYPE' => 'application/json' } } + let(:success_log_args) do + { + message: 'Overall claim submission success!', + user_uuid: user.uuid, + action: 'Overall claim submission', + form_id: '995', + upstream_system: nil, + downstream_system: 'Lighthouse', + is_success: true, + http: { + status_code: 200, + body: '[Redacted]' + } + } + end + let(:error_log_args) do + { + message: 'Overall claim submission failure!', + user_uuid: user.uuid, + action: 'Overall claim submission', + form_id: '995', + upstream_system: nil, + downstream_system: 'Lighthouse', + is_success: false, + http: { + status_code: 422, + body: response_error_body + } + } + end + let(:extra_error_log_message) do + 'BackendServiceException: ' \ + '{:source=>"Common::Client::Errors::ClientError raised in DecisionReviews::V1::Service", :code=>"DR_422"}' + end + + let(:response_error_body) do + { + 'errors' => [{ 'title' => 'Missing required fields', + 'detail' => 'One or more expected fields were not found', + 'code' => '145', + 'source' => { 'pointer' => '/data/attributes' }, + 'status' => '422', + 'meta' => { 'missing_fields' => ['form5103Acknowledged'] } }] + } + end + + before { sign_in_as(user) } + + describe '#create' do + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V1::SupplementalClaimsController#create exception % (SC_V1)' + end + + subject do + post '/decision_reviews/v1/supplemental_claims', + params: VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY_V1').to_json, + headers: + end + + it 'creates a supplemental claim' do + VCR.use_cassette('decision_review/SC-CREATE-RESPONSE-200_V1') do + # Create an InProgressForm + in_progress_form = create(:in_progress_form, user_uuid: user.uuid, form_id: '20-0995') + expect(in_progress_form).not_to be_nil + previous_appeal_submission_ids = AppealSubmission.all.pluck :submitted_appeal_uuid + + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(success_log_args) + allow(StatsD).to receive(:increment) + expect(StatsD).to receive(:increment).with('decision_review.form_995.overall_claim_submission.success') + + subject + expect(response).to be_successful + parsed_response = JSON.parse(response.body) + id = parsed_response['data']['id'] + expect(previous_appeal_submission_ids).not_to include id + appeal_submission = AppealSubmission.find_by(submitted_appeal_uuid: id) + expect(appeal_submission.type_of_appeal).to eq('SC') + # InProgressForm should be destroyed after successful submission + in_progress_form = InProgressForm.find_by(user_uuid: user.uuid, form_id: '20-0995') + expect(in_progress_form).to be_nil + # SavedClaim should be created with request data + saved_claim = SavedClaim::SupplementalClaim.find_by(guid: id) + expect(saved_claim.form).to eq(VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY_V1').to_json) + expect(saved_claim.uploaded_forms).to be_empty + end + end + + it 'adds to the PersonalInformationLog when an exception is thrown' do + VCR.use_cassette('decision_review/SC-CREATE-RESPONSE-422_V1') do + expect(personal_information_logs.count).to be 0 + allow(Rails.logger).to receive(:error) + expect(Rails.logger).to receive(:error).with(error_log_args) + expect(Rails.logger).to receive(:error).with( + message: "Exception occurred while submitting Supplemental Claim: #{extra_error_log_message}", + backtrace: anything + ) + expect(Rails.logger).to receive(:error).with(extra_error_log_message, anything) + allow(StatsD).to receive(:increment) + expect(StatsD).to receive(:increment).with('decision_review.form_995.overall_claim_submission.failure') + + subject + expect(personal_information_logs.count).to be 1 + pil = personal_information_logs.first + %w[ + first_name last_name birls_id icn edipi mhv_correlation_id + participant_id vet360_id ssn assurance_level birth_date + ].each { |key| expect(pil.data['user'][key]).to be_truthy } + %w[message backtrace key response_values original_status original_body] + .each { |key| expect(pil.data['error'][key]).to be_truthy } + expect(pil.data['additional_data']['request']['body']).not_to be_empty + end + end + end + + describe '#create with 4142' do + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V1::SupplementalClaimsController#create exception % (SC_V1)' + end + + context 'when tracking 4142 is enabled' do + subject do + post '/decision_reviews/v1/supplemental_claims', + params: VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY-FOR-VA-GOV').to_json, + headers: + end + + before do + Flipper.enable(:decision_review_track_4142_submissions) + end + + it 'creates a supplemental claim and queues and saves a 4142 form when 4142 info is provided' do + VCR.use_cassette('decision_review/SC-CREATE-RESPONSE-WITH-4142-200_V1') do + VCR.use_cassette('lighthouse/benefits_intake/200_lighthouse_intake_upload_location') do + VCR.use_cassette('lighthouse/benefits_intake/200_lighthouse_intake_upload') do + previous_appeal_submission_ids = AppealSubmission.all.pluck :submitted_appeal_uuid + expect { subject }.to change(DecisionReview::Form4142Submit.jobs, :size).by(1) + expect(response).to be_successful + parsed_response = JSON.parse(response.body) + id = parsed_response['data']['id'] + expect(previous_appeal_submission_ids).not_to include id + appeal_submission = AppealSubmission.find_by(submitted_appeal_uuid: id) + expect(appeal_submission.type_of_appeal).to eq('SC') + expect do + DecisionReview::Form4142Submit.drain + end.to change(DecisionReview::Form4142Submit.jobs, :size).by(-1) + + # SavedClaim should be created with request data and list of uploaded forms + request_body = JSON.parse(VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY-FOR-VA-GOV').to_json) + saved_claim = SavedClaim::SupplementalClaim.find_by(guid: id) + expect(saved_claim.form).to eq(request_body.to_json) + expect(saved_claim.uploaded_forms).to contain_exactly '21-4142' + + # SecondaryAppealForm should be created with 4142 data and user data + expected_form4142_data = VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY-FOR-VA-GOV')['form4142'] + veteran_data = { + 'vaFileNumber' => '796111863', + 'veteranSocialSecurityNumber' => '796111863', + 'veteranFullName' => { + 'first' => 'abraham', + 'middle' => nil, + 'last' => 'lincoln' + }, + 'veteranDateOfBirth' => '1809-02-12', + 'veteranAddress' => { 'addressLine1' => '123 Main St', 'city' => 'New York', 'countryCodeISO2' => 'US', + 'zipCode5' => '30012', 'country' => 'US', 'postalCode' => '30012' }, + 'email' => 'josie@example.com', + 'veteranPhone' => '5558001111' + } + expected_form4142_data_with_user = veteran_data.merge(expected_form4142_data) + saved4142 = SecondaryAppealForm.last + saved_4142_json = JSON.parse(saved4142.form) + expect(saved_4142_json).to eq(expected_form4142_data_with_user) + expect(saved4142.form_id).to eq('21-4142') + expect(saved4142.appeal_submission.id).to eq(appeal_submission.id) + end + end + end + end + end + + context 'when tracking 4142 is disabled' do + before do + Flipper.disable(:decision_review_track_4142_submissions) + end + + it 'creates a supplemental claim and queues a 4142 form when 4142 info is provided' do + VCR.use_cassette('decision_review/SC-CREATE-RESPONSE-WITH-4142-200_V1') do + VCR.use_cassette('lighthouse/benefits_intake/200_lighthouse_intake_upload_location') do + VCR.use_cassette('lighthouse/benefits_intake/200_lighthouse_intake_upload') do + previous_appeal_submission_ids = AppealSubmission.all.pluck :submitted_appeal_uuid + expect do + post '/decision_reviews/v1/supplemental_claims', + params: VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY-FOR-VA-GOV').to_json, + headers: + end.to change(DecisionReview::Form4142Submit.jobs, :size).by(1) + expect(response).to be_successful + parsed_response = JSON.parse(response.body) + id = parsed_response['data']['id'] + expect(previous_appeal_submission_ids).not_to include id + appeal_submission = AppealSubmission.find_by(submitted_appeal_uuid: id) + expect(appeal_submission.type_of_appeal).to eq('SC') + expect do + DecisionReview::Form4142Submit.drain + end.to change(DecisionReview::Form4142Submit.jobs, :size).by(-1) + + # SavedClaim should be created with request data and list of uploaded forms + request_body = JSON.parse(VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY-FOR-VA-GOV').to_json) + saved_claim = SavedClaim::SupplementalClaim.find_by(guid: id) + expect(saved_claim.form).to eq(request_body.to_json) + expect(saved_claim.uploaded_forms).to contain_exactly '21-4142' + end + end + end + end + + it 'does not persist a SecondaryAppealForm for the 4142' do + VCR.use_cassette('decision_review/SC-CREATE-RESPONSE-WITH-4142-200_V1') do + VCR.use_cassette('lighthouse/benefits_intake/200_lighthouse_intake_upload_location') do + VCR.use_cassette('lighthouse/benefits_intake/200_lighthouse_intake_upload') do + expect do + post '/decision_reviews/v1/supplemental_claims', + params: VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY-FOR-VA-GOV').to_json, + headers: + end.to change(DecisionReview::Form4142Submit.jobs, :size).by(1) + expect do + DecisionReview::Form4142Submit.drain + end.not_to change(SecondaryAppealForm, :count) + end + end + end + end + end + end + + describe '#create with uploads' do + # Create evidence files objs + + subject do + post '/decision_reviews/v1/supplemental_claims', + params: example_payload.to_json, + headers: + end + + let(:example_payload) { VetsJsonSchema::EXAMPLES.fetch('SC-CREATE-REQUEST-BODY-FOR-VA-GOV') } + + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V1::SupplementalClaimsController#create exception % (SC_V1)' + end + + it 'creates a supplemental claim and queues evidence jobs when additionalDocuments info is provided' do + VCR.use_cassette('decision_review/SC-CREATE-RESPONSE-WITH-UPLOADS-200_V1') do + VCR.use_cassette('lighthouse/benefits_intake/200_lighthouse_intake_upload_location') do + VCR.use_cassette('lighthouse/benefits_intake/200_lighthouse_intake_upload') do + VCR.use_cassette('decision_review/SC-GET-UPLOAD-URL-200_V1') do + expect { subject }.to change(DecisionReview::SubmitUpload.jobs, :size).by(2) + expect(response).to be_successful + parsed_response = JSON.parse(response.body) + id = parsed_response['data']['id'] + appeal_submission = AppealSubmission.find_by(submitted_appeal_uuid: id) + expect(appeal_submission.type_of_appeal).to eq('SC') + end + end + end + end + end + + context 'when an error occurs in the transaction' do + shared_examples 'rolledback transaction' do |model| + before do + allow_any_instance_of(model).to receive(:save!).and_raise(ActiveModel::Error) # stub a model error + end + + it 'rollsback transaction' do + VCR.use_cassette('decision_review/SC-CREATE-RESPONSE-WITH-UPLOADS-200_V1') do + expect(subject).to eq 500 + + # check that transaction rolled back / records were not persisted / evidence upload job was not queued up + expect(AppealSubmission.count).to eq 0 + expect(AppealSubmissionUpload.count).to eq 0 + expect(DecisionReview::SubmitUpload).not_to have_enqueued_sidekiq_job(anything) + expect(SavedClaim.count).to eq 0 + expect(SecondaryAppealForm.count).to eq 0 + end + end + end + + context 'for AppealSubmission' do + it_behaves_like 'rolledback transaction', AppealSubmission + end + + context 'for SavedClaim' do + it_behaves_like 'rolledback transaction', SavedClaim + end + end + end +end diff --git a/modules/decision_reviews/spec/requests/v2/higher_level_reviews_spec.rb b/modules/decision_reviews/spec/requests/v2/higher_level_reviews_spec.rb new file mode 100644 index 00000000000..81bcd4f5711 --- /dev/null +++ b/modules/decision_reviews/spec/requests/v2/higher_level_reviews_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/controller_spec_helper' + +RSpec.describe 'DecisionReviews::V2::HigherLevelReviews', type: :request do + let(:user) { build(:user, :loa3) } + let(:headers) { { 'CONTENT_TYPE' => 'application/json' } } + let(:success_log_args) do + { + message: 'Overall claim submission success!', + user_uuid: user.uuid, + action: 'Overall claim submission', + form_id: '996', + upstream_system: nil, + downstream_system: 'Lighthouse', + is_success: true, + http: { + status_code: 200, + body: '[Redacted]' + }, + version: 'V2' + } + end + let(:error_log_args) do + { + message: 'Overall claim submission failure!', + user_uuid: user.uuid, + action: 'Overall claim submission', + form_id: '996', + upstream_system: nil, + downstream_system: 'Lighthouse', + is_success: false, + http: { + status_code: 422, + body: response_error_body + }, + version: 'V2' + } + end + + let(:response_error_body) do + { + 'errors' => [{ 'title' => 'Missing required fields', + 'detail' => 'One or more expected fields were not found', + 'code' => '145', + 'source' => { 'pointer' => '/' }, + 'status' => '422', + 'meta' => { 'missing_fields' => %w[data included] } }] + } + end + + let(:extra_error_log_message) do + 'BackendServiceException: {:source=>"Common::Client::Errors::ClientError raised in DecisionReviews::V1::Service", :code=>"DR_422"}' # rubocop:disable Layout/LineLength + end + + before { sign_in_as(user) } + + describe '#create' do + def personal_information_logs + PersonalInformationLog.where 'error_class like ?', + 'DecisionReviews::V2::HigherLevelReviewsController#create exception % (HLR_V2)' + end + + subject do + post '/decision_reviews/v2/higher_level_reviews', + params: VetsJsonSchema::EXAMPLES.fetch('HLR-CREATE-REQUEST-BODY_V1').to_json, + headers: + end + + it 'creates an HLR' do + VCR.use_cassette('decision_review/HLR-CREATE-RESPONSE-200_V1') do + # Create an InProgressForm + in_progress_form = create(:in_progress_form, user_uuid: user.uuid, form_id: '20-0996') + expect(in_progress_form).not_to be_nil + + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(success_log_args) + allow(StatsD).to receive(:increment) + expect(StatsD).to receive(:increment).with('decision_review.form_996.overall_claim_submission.success') + + subject + expect(response).to be_successful + appeal_uuid = JSON.parse(response.body)['data']['id'] + expect(AppealSubmission.where(submitted_appeal_uuid: appeal_uuid).first).to be_truthy + # InProgressForm should be destroyed after successful submission + in_progress_form = InProgressForm.find_by(user_uuid: user.uuid, form_id: '20-0996') + expect(in_progress_form).to be_nil + # SavedClaim should be created with request data + saved_claim = SavedClaim::HigherLevelReview.find_by(guid: appeal_uuid) + expect(saved_claim.form).to eq(VetsJsonSchema::EXAMPLES.fetch('HLR-CREATE-REQUEST-BODY_V1').to_json) + end + end + + context 'when an error occurs with the api call' do + it 'adds to the PersonalInformationLog' do + VCR.use_cassette('decision_review/HLR-CREATE-RESPONSE-422_V1') do + expect(personal_information_logs.count).to be 0 + + allow(Rails.logger).to receive(:error) + expect(Rails.logger).to receive(:error).with(error_log_args) + expect(Rails.logger).to receive(:error).with( + message: "Exception occurred while submitting Higher Level Review: #{extra_error_log_message}", + backtrace: anything + ) + expect(Rails.logger).to receive(:error).with(extra_error_log_message, anything) + allow(StatsD).to receive(:increment) + expect(StatsD).to receive(:increment).with('decision_review.form_996.overall_claim_submission.failure') + + subject + expect(personal_information_logs.count).to be 1 + pil = personal_information_logs.first + %w[ + first_name last_name birls_id icn edipi mhv_correlation_id + participant_id vet360_id ssn assurance_level birth_date + ].each { |key| expect(pil.data['user'][key]).to be_truthy } + %w[message backtrace key response_values original_status original_body] + .each { |key| expect(pil.data['error'][key]).to be_truthy } + expect(pil.data['additional_data']['request']['body']).not_to be_empty + end + end + end + + context 'when an error occurs in the transaction' do + shared_examples 'rolledback transaction' do |model| + before do + allow_any_instance_of(model).to receive(:save!).and_raise(ActiveModel::Error) # stub a model error + end + + it 'rollsback transaction' do + VCR.use_cassette('decision_review/HLR-CREATE-RESPONSE-200_V1') do + expect(subject).to eq 500 + + # check that transaction rolled back / records were not persisted + expect(AppealSubmission.count).to eq 0 + expect(SavedClaim.count).to eq 0 + end + end + end + + context 'for AppealSubmission' do + it_behaves_like 'rolledback transaction', AppealSubmission + end + + context 'for SavedClaim' do + it_behaves_like 'rolledback transaction', SavedClaim + end + end + end +end