diff --git a/app/queries/veteran_record_requests_open_for_vre_query.rb b/app/queries/veteran_record_requests_open_for_vre_query.rb new file mode 100644 index 00000000000..f4e5766f166 --- /dev/null +++ b/app/queries/veteran_record_requests_open_for_vre_query.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class VeteranRecordRequestsOpenForVREQuery + # @return [ActiveRecord::Relation] VeteranRecordRequest tasks that are + # both open and assigned to the 'Veterans Readiness and Employment' business + # line (f.k.a 'Vocational Rehabilitation and Employment') + def self.call + vre_business_line = + BusinessLine.where(name: Constants::BENEFIT_TYPES["voc_rehab"]) + + VeteranRecordRequest.open.where(assigned_to: vre_business_line) + end +end diff --git a/lib/helpers/cancel_tasks_and_descendants.rb b/lib/helpers/cancel_tasks_and_descendants.rb new file mode 100644 index 00000000000..fc87251d0d3 --- /dev/null +++ b/lib/helpers/cancel_tasks_and_descendants.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "securerandom" + +class CancelTasksAndDescendants + LOG_TAG = "CancelTasksAndDescendants" + + # Cancels all tasks and descendant tasks for given Task relation + # + # @param task_relation [ActiveRecord::Relation] tasks to be cancelled + # @return [NilClass] + def self.call(task_relation = Task.none) + new(task_relation).__send__(:call) + end + + private + + def initialize(task_relation) + @task_relation = task_relation + @request_id = SecureRandom.uuid + @logs = [] + end + + def call + RequestStore[:current_user] = User.system_user + + with_paper_trail_options do + log_time_elapsed { log_task_count_before_and_after { cancel_tasks } } + print_logs_to_stdout + end + end + + # @note Temporarily sets the PaperTrail request options and executes the given + # block. The request options are only in effect on the current thread for + # the duration of the block. + # This is needed so that the PaperTrail `versions` records for cancelled + # tasks reflect the appropriate `whodunnit` and `request_id`. + def with_paper_trail_options(&block) + options = { whodunnit: User.system_user.id, + controller_info: { request_id: @request_id } } + + PaperTrail.request(options, &block) + end + + def cancel_tasks + @task_relation.find_each do |task| + log_cancelled(task) { task.cancel_task_and_child_subtasks } + rescue StandardError => error + log_errored(task, error) + end + end + + def log_cancelled(task, &block) + task_ids = cancellable_descendants_for(task).pluck(:id) + yield(block) + log("Task ids #{task_ids} cancelled successfully") + end + + def log_errored(task, error) + task_ids = cancellable_descendants_for(task).pluck(:id) + log("Task ids #{task_ids} not cancelled due to error - #{error}", + level: :error) + end + + def cancellable_descendants_for(task) + # Note: The result of `Task #descendants` also includes the instance itself + Task.open.where(id: task.descendants) + end + + def log_task_count_before_and_after(&block) + initial_count = count_of_cancellable_tasks + log_total_tasks_for_cancellation(initial_count) + yield(block) + final_count = initial_count - count_of_cancellable_tasks + log_cancelled_successfully(final_count) + end + + def count_of_cancellable_tasks + sum = 0 + @task_relation.find_each do |task| + sum += cancellable_descendants_for(task).count + end + sum + end + + def log_total_tasks_for_cancellation(count) + log("Total tasks for cancellation: #{count}") + end + + def log_cancelled_successfully(count) + log("Tasks cancelled successfully: #{count}") + end + + def log_time_elapsed(&block) + time_elapsed_in_seconds = Benchmark.realtime(&block) + log("Elapsed time (sec): #{time_elapsed_in_seconds}") + end + + def log(message, level: :info) + append_to_application_logs(level, message) + append_to_logs_for_stdout(message) + end + + def append_to_application_logs(level, message) + Rails.logger.tagged(LOG_TAG, @request_id) do + Rails.logger.public_send(level, message) + end + end + + def append_to_logs_for_stdout(message) + @logs << "[#{LOG_TAG}] [#{@request_id}] #{message}" + end + + def print_logs_to_stdout + puts @logs + end +end diff --git a/lib/tasks/remediations/cancel_vrr_tasks_open_for_vre.rake b/lib/tasks/remediations/cancel_vrr_tasks_open_for_vre.rake new file mode 100644 index 00000000000..b76b86a6dd5 --- /dev/null +++ b/lib/tasks/remediations/cancel_vrr_tasks_open_for_vre.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative "../../../lib/helpers/cancel_tasks_and_descendants" + +namespace :remediations do + desc "Cancel VeteranRecordRequest tasks that are both open and assigned to " \ + "the 'Veterans Readiness and Employment' business line" + task cancel_vrr_tasks_open_for_vre: [:environment] do + CancelTasksAndDescendants.call( + VeteranRecordRequestsOpenForVREQuery.call + ) + end +end diff --git a/spec/factories/organization.rb b/spec/factories/organization.rb index 0ccf7fc1bf4..7d5fdb08bab 100644 --- a/spec/factories/organization.rb +++ b/spec/factories/organization.rb @@ -70,6 +70,11 @@ type { "BusinessLine" } end + factory :vre_business_line, class: BusinessLine do + type { "BusinessLine" } + name { Constants::BENEFIT_TYPES["voc_rehab"] } + end + factory :hearings_management do type { "HearingsManagement" } name { "Hearings Management" } diff --git a/spec/helpers/cancel_tasks_and_descendants_spec.rb b/spec/helpers/cancel_tasks_and_descendants_spec.rb new file mode 100644 index 00000000000..3b1bc195617 --- /dev/null +++ b/spec/helpers/cancel_tasks_and_descendants_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require "helpers/cancel_tasks_and_descendants" +require "securerandom" + +describe CancelTasksAndDescendants do + describe ".call" do + context "when task_relation is not given" do + subject(:call) { described_class.call } + + it "assigns RequestStore[:current_user]" do + expect { call }.to change { RequestStore[:current_user] } + .from(nil).to(User.system_user) + end + + it "appends appropriate logs to application logs" do + rails_logger = Rails.logger + allow(Rails).to receive(:logger).and_return(rails_logger) + + aggregate_failures do + expect(rails_logger).to receive(:info) + .with("Total tasks for cancellation: 0").ordered + + expect(rails_logger).not_to receive(:info) + .with(/Task ids \[.+\] cancelled successfully/) + + expect(rails_logger).to receive(:info) + .with(/Tasks cancelled successfully: 0/).ordered + + expect(rails_logger).to receive(:info) + .with(/Elapsed time \(sec\):/).ordered + end + + call + end + + it "appends appropriate logs to stdout" do + allow(SecureRandom).to receive(:uuid) { "dummy-request-id" } + + # rubocop:disable Layout/FirstArgumentIndentation + expect { call }.to output( + match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Total tasks for cancellation: 0" + )).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Tasks cancelled successfully: 0" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Elapsed time (sec):" + ))) + ).to_stdout + # rubocop:enable Layout/FirstArgumentIndentation + end + + it { is_expected.to be_nil } + end + + context "when task_relation is given " do + subject(:call) { described_class.call(task_relation) } + + let(:task_relation) { Task.where(id: [task_1, task_2, task_3]) } + let(:task_1) { create(:veteran_record_request_task) } + let(:task_2) { create(:veteran_record_request_task) } + let(:task_3) { create(:veteran_record_request_task) } + + before do + expect(task_relation).to receive(:find_each).at_least(:once) + .and_yield(task_1) + .and_yield(task_2) + .and_yield(task_3) + + allow(task_1).to receive(:self_and_descendants) { [task_1] } + allow(task_2).to receive(:self_and_descendants) { [task_2] } + allow(task_3).to receive(:self_and_descendants) { [task_3] } + end + + it "cancels each task and its descendants" do + aggregate_failures do + expect(task_1).to receive(:cancel_task_and_child_subtasks) + expect(task_2).to receive(:cancel_task_and_child_subtasks) + expect(task_3).to receive(:cancel_task_and_child_subtasks) + end + call + end + + it "sets PaperTrail versions data appropriately for cancelled tasks" do + request_id = SecureRandom.uuid + allow(SecureRandom).to receive(:uuid) { request_id } + + call + + task_1_version = task_1.versions.last + expect(task_1_version.whodunnit).to eq(User.system_user.id.to_s) + expect(task_1_version.request_id).to eq(request_id) + end + + it "appends appropriate logs to application logs" do + rails_logger = Rails.logger + allow(Rails).to receive(:logger).and_return(rails_logger) + + aggregate_failures do + expect(rails_logger).to receive(:info) + .with("Total tasks for cancellation: 3").ordered + + expect(rails_logger).to receive(:info) + .with(/Task ids \[#{task_1.id}\] cancelled successfully/).ordered + + expect(rails_logger).to receive(:info) + .with(/Task ids \[#{task_2.id}\] cancelled successfully/).ordered + + expect(rails_logger).to receive(:info) + .with(/Task ids \[#{task_3.id}\] cancelled successfully/).ordered + + expect(rails_logger).to receive(:info) + .with(/Tasks cancelled successfully: 3/).ordered + + expect(rails_logger).to receive(:info) + .with(/Elapsed time \(sec\):/).ordered + end + + call + end + + it "appends appropriate logs to stdout" do + allow(SecureRandom).to receive(:uuid) { "dummy-request-id" } + + # rubocop:disable Layout/FirstArgumentIndentation + expect { call }.to output( + match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Total tasks for cancellation: 3" + )).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Task ids [#{task_1.id}] cancelled successfully" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Task ids [#{task_2.id}] cancelled successfully" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Task ids [#{task_3.id}] cancelled successfully" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Tasks cancelled successfully: 3" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Elapsed time (sec):" + ))) + ).to_stdout + # rubocop:enable Layout/FirstArgumentIndentation + end + + context "when a task fails to cancel" do + before do + expect(task_2).to receive(:cancel_task_and_child_subtasks) + .and_raise(ActiveModel::ValidationError.new(Task.new)) + end + + it "does not prevent cancellation of other tasks in the relation" do + aggregate_failures do + expect(task_1).to receive(:cancel_task_and_child_subtasks) + expect(task_3).to receive(:cancel_task_and_child_subtasks) + end + call + end + + it "does not raise error" do + expect { call }.not_to raise_error + end + + it "appends appropriate logs to application logs" do + rails_logger = Rails.logger + allow(Rails).to receive(:logger).and_return(rails_logger) + + aggregate_failures do + expect(rails_logger).to receive(:info) + .with("Total tasks for cancellation: 3").ordered + + expect(rails_logger).to receive(:info) + .with(/Task ids \[#{task_1.id}\] cancelled successfully/).ordered + + expect(rails_logger).to receive(:error).with( + /Task ids \[#{task_2.id}\] not cancelled due to error - Validation failed/ + ).ordered + + expect(rails_logger).to receive(:info) + .with(/Task ids \[#{task_3.id}\] cancelled successfully/).ordered + + expect(rails_logger).to receive(:info) + .with(/Tasks cancelled successfully: 2/).ordered + + expect(rails_logger).to receive(:info) + .with(/Elapsed time \(sec\):/).ordered + end + + call + end + + it "appends appropriate logs to stdout" do + allow(SecureRandom).to receive(:uuid) { "dummy-request-id" } + + # rubocop:disable Layout/FirstArgumentIndentation + expect { call }.to output( + match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Total tasks for cancellation: 3" + )).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Task ids [#{task_1.id}] cancelled successfully" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Task ids [#{task_2.id}] not cancelled due to error - " \ + "Validation failed" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Task ids [#{task_3.id}] cancelled successfully" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Tasks cancelled successfully: 2" + ))).and(match(Regexp.escape( + "[CancelTasksAndDescendants] [dummy-request-id] Elapsed time (sec):" + ))) + ).to_stdout + # rubocop:enable Layout/FirstArgumentIndentation + end + + it { is_expected.to be_nil } + end + end + end +end diff --git a/spec/lib/tasks/remediations/cancel_vrr_tasks_open_for_vre_spec.rb b/spec/lib/tasks/remediations/cancel_vrr_tasks_open_for_vre_spec.rb new file mode 100644 index 00000000000..ce95ddc0278 --- /dev/null +++ b/spec/lib/tasks/remediations/cancel_vrr_tasks_open_for_vre_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +describe "remediations/cancel_vrr_tasks_open_for_vre" do + include_context "rake" + + describe "remediations:cancel_vrr_tasks_open_for_vre" do + it "delegates to CancelTasksAndDescendants" do + task_relation = double("task_relation") + + expect(VeteranRecordRequestsOpenForVREQuery) + .to receive(:call).and_return(task_relation) + + expect(CancelTasksAndDescendants).to receive(:call).with(task_relation) + + Rake::Task["remediations:cancel_vrr_tasks_open_for_vre"].invoke + end + end +end diff --git a/spec/queries/veteran_record_requests_open_for_vre_query_spec.rb b/spec/queries/veteran_record_requests_open_for_vre_query_spec.rb new file mode 100644 index 00000000000..8a531724f49 --- /dev/null +++ b/spec/queries/veteran_record_requests_open_for_vre_query_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +describe VeteranRecordRequestsOpenForVREQuery do + describe ".call" do + subject(:call) { described_class.call } + + context "when there are no VeteranRecordRequests" do + let!(:task) { create(:task) } + + it "is none" do + result = call + + expect(result).to be_a_kind_of(ActiveRecord::Relation) + expect(result).to be_none + end + end + + context "when there are VeteranRecordRequests" do + let!(:cancelled_for_vre) { create(:veteran_record_request_task, :cancelled, assigned_to: vre_business_line) } + let!(:complete_for_vre) { create(:veteran_record_request_task, :completed, assigned_to: vre_business_line) } + + let!(:assigned_for_vre) { create(:veteran_record_request_task, assigned_to: vre_business_line) } + let!(:in_progress_for_vre) { create(:veteran_record_request_task, :in_progress, assigned_to: vre_business_line) } + let!(:on_hold_for_vre) { create(:veteran_record_request_task, :on_hold, assigned_to: vre_business_line) } + + let!(:assigned) { create(:veteran_record_request_task, assigned_to: non_vre_organization) } + let!(:in_progress) { create(:veteran_record_request_task, :in_progress, assigned_to: non_vre_organization) } + let!(:on_hold) { create(:veteran_record_request_task, :on_hold, assigned_to: non_vre_organization) } + + let(:vre_business_line) { create(:vre_business_line) } + let(:non_vre_organization) { create(:organization) } + + it "only returns those that are both open and assigned to the VRE business line" do + result = call + + expect(result).to be_a_kind_of(ActiveRecord::Relation) + expect(result).to contain_exactly( + assigned_for_vre, + in_progress_for_vre, + on_hold_for_vre + ) + end + end + end +end