# frozen_string_literal: true

require "spec_helper"

RSpec.describe Gitlab::Database::Migrations::SidekiqHelpers do
  let(:model) do
    ActiveRecord::Migration.new.extend(described_class)
  end

  describe "sidekiq migration helpers", :redis do
    let(:worker) do
      Class.new do
        include Sidekiq::Worker

        sidekiq_options queue: "test"

        def self.name
          "WorkerClass"
        end
      end
    end

    let(:worker_two) do
      Class.new do
        include Sidekiq::Worker

        sidekiq_options queue: "test_two"

        def self.name
          "WorkerTwoClass"
        end
      end
    end

    let(:same_queue_different_worker) do
      Class.new do
        include Sidekiq::Worker

        sidekiq_options queue: "test"

        def self.name
          "SameQueueDifferentWorkerClass"
        end
      end
    end

    let(:unrelated_worker) do
      Class.new do
        include Sidekiq::Worker

        sidekiq_options queue: "unrelated"

        def self.name
          "UnrelatedWorkerClass"
        end
      end
    end

    before do
      stub_const(worker.name, worker)
      stub_const(worker_two.name, worker_two)
      stub_const(unrelated_worker.name, unrelated_worker)
      stub_const(same_queue_different_worker.name, same_queue_different_worker)
    end

    describe "#sidekiq_remove_jobs", :clean_gitlab_redis_queues do
      def clear_queues
        Sidekiq::Queue.new("test").clear
        Sidekiq::Queue.new("test_two").clear
        Sidekiq::Queue.new("unrelated").clear
        Sidekiq::RetrySet.new.clear
        Sidekiq::ScheduledSet.new.clear
      end

      around do |example|
        clear_queues
        Sidekiq::Testing.disable!(&example)
        clear_queues
      end

      context "when the constant is not defined" do
        it "doesn't try to delete it" do
          my_non_constant = +"SomeThingThatIsNotAConstant"

          expect(Sidekiq::Queue).not_to receive(:new).with(any_args)
          model.sidekiq_remove_jobs(job_klasses: [my_non_constant])
        end
      end

      context "when the constant is defined" do
        it "will use it find job instances to delete" do
          my_constant = worker.name
          expect(Sidekiq::Queue)
            .to receive(:new)
            .with(worker.queue)
            .and_call_original
          model.sidekiq_remove_jobs(job_klasses: [my_constant])
        end
      end

      it "removes all related job instances from the job classes' queues" do
        worker.perform_async
        worker_two.perform_async
        same_queue_different_worker.perform_async
        unrelated_worker.perform_async

        worker_queue = Sidekiq::Queue.new(worker.queue)
        worker_two_queue = Sidekiq::Queue.new(worker_two.queue)
        unrelated_queue = Sidekiq::Queue.new(unrelated_worker.queue)

        expect(worker_queue.size).to eq(2)
        expect(worker_two_queue.size).to eq(1)
        expect(unrelated_queue.size).to eq(1)

        model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name])

        expect(worker_queue.size).to eq(1)
        expect(worker_two_queue.size).to eq(0)
        expect(worker_queue.map(&:klass)).not_to include(worker.name)
        expect(worker_queue.map(&:klass)).to include(
          same_queue_different_worker.name
        )
        expect(worker_two_queue.map(&:klass)).not_to include(worker_two.name)
        expect(unrelated_queue.size).to eq(1)
      end

      context "when job instances are in the scheduled set" do
        it "removes all related job instances from the scheduled set" do
          worker.perform_in(1.hour)
          worker_two.perform_in(1.hour)
          unrelated_worker.perform_in(1.hour)

          scheduled = Sidekiq::ScheduledSet.new

          expect(scheduled.size).to eq(3)
          expect(scheduled.map(&:klass)).to include(
            worker.name,
            worker_two.name,
            unrelated_worker.name
          )

          model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name])

          expect(scheduled.size).to eq(1)
          expect(scheduled.map(&:klass)).not_to include(worker.name)
          expect(scheduled.map(&:klass)).not_to include(worker_two.name)
          expect(scheduled.map(&:klass)).to include(unrelated_worker.name)
        end
      end

      context "when job instances are in the retry set" do
        include_context "when handling retried jobs"

        it "removes all related job instances from the retry set" do
          retry_in(worker, 1.hour)
          retry_in(worker, 2.hours)
          retry_in(worker, 3.hours)
          retry_in(worker_two, 4.hours)
          retry_in(unrelated_worker, 5.hours)

          retries = Sidekiq::RetrySet.new

          expect(retries.size).to eq(5)
          expect(retries.map(&:klass)).to include(
            worker.name,
            worker_two.name,
            unrelated_worker.name
          )

          model.sidekiq_remove_jobs(job_klasses: [worker.name, worker_two.name])

          expect(retries.size).to eq(1)
          expect(retries.map(&:klass)).not_to include(worker.name)
          expect(retries.map(&:klass)).not_to include(worker_two.name)
          expect(retries.map(&:klass)).to include(unrelated_worker.name)
        end
      end

      # Imitate job deletion returning zero and then non zero.
      context "when job fails to be deleted" do
        let(:job_double) do
          instance_double(
            "Sidekiq::JobRecord",
            klass: worker.name
          )
        end

        context "and does not work enough times in a row before max attempts" do
          it "tries the max attempts without succeeding" do
            worker.perform_async

            allow(job_double).to receive(:delete).and_return(true)

            # Scheduled set runs last so only need to stub out its values.
            allow(Sidekiq::ScheduledSet)
              .to receive(:new)
              .and_return([job_double])

            expect(model.sidekiq_remove_jobs(job_klasses: [worker.name]))
              .to eq(
                {
                  attempts: 5,
                  success: false
                }
              )
          end
        end

        context "and then it works enough times in a row before max attempts" do
          it "succeeds" do
            worker.perform_async

            # attempt 1: false will increment the streak once to 1
            # attempt 2: true resets it back to 0
            # attempt 3: false will increment the streak once to 1
            # attempt 4: false will increment the streak once to 2, loop breaks
            allow(job_double).to receive(:delete).and_return(false, true, false)

            worker.perform_async

            # Scheduled set runs last so only need to stub out its values.
            allow(Sidekiq::ScheduledSet)
              .to receive(:new)
              .and_return([job_double])

            expect(model.sidekiq_remove_jobs(job_klasses: [worker.name]))
              .to eq(
                {
                  attempts: 4,
                  success: true
                }
              )
          end
        end
      end
    end

    describe "#sidekiq_queue_length" do
      context "when queue is empty" do
        it "returns zero" do
          Sidekiq::Testing.disable! do
            expect(model.sidekiq_queue_length("test")).to eq 0
          end
        end
      end

      context "when queue contains jobs" do
        it "returns correct size of the queue" do
          Sidekiq::Testing.disable! do
            worker.perform_async("Something", [1])
            worker.perform_async("Something", [2])

            expect(model.sidekiq_queue_length("test")).to eq 2
          end
        end
      end
    end

    describe "#sidekiq_queue_migrate" do
      it "migrates jobs from one sidekiq queue to another" do
        Sidekiq::Testing.disable! do
          worker.perform_async("Something", [1])
          worker.perform_async("Something", [2])

          expect(model.sidekiq_queue_length("test")).to eq 2
          expect(model.sidekiq_queue_length("new_test")).to eq 0

          model.sidekiq_queue_migrate("test", to: "new_test")

          expect(model.sidekiq_queue_length("test")).to eq 0
          expect(model.sidekiq_queue_length("new_test")).to eq 2
        end
      end
    end
  end
end