gitlabhq/gitlabhq

View on GitHub
qa/spec/support/repeater_spec.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

require 'logger'
require 'timecop'
require 'active_support/core_ext/integer/time'

describe QA::Support::Repeater do
  before do
    logger = ::Logger.new $stdout
    logger.level = ::Logger::DEBUG
    QA::Runtime::Logger.logger = logger
  end

  subject do
    Module.new do
      extend QA::Support::Repeater
    end
  end

  let(:time_start) { Time.now }
  let(:return_value) { "test passed" }

  describe '.repeat_until' do
    context 'when raise_on_failure is not provided (default: true)' do
      context 'when retry_on_exception is not provided (default: false)' do
        context 'when max_duration is provided' do
          context 'when max duration is reached' do
            it 'raises an exception' do
              expect do
                Timecop.freeze do
                  subject.repeat_until(max_duration: 1) do
                    Timecop.travel(2)
                    false
                  end
                end
              end.to raise_error(QA::Support::Repeater::WaitExceededError, "Wait condition not met after 1 second")
            end

            it 'ignores attempts' do
              loop_counter = 0

              expect(
                Timecop.freeze do
                  subject.repeat_until(max_duration: 1) do
                    loop_counter += 1

                    if loop_counter > 3
                      Timecop.travel(1)
                      return_value
                    else
                      false
                    end
                  end
                end
              ).to eq(return_value)
              expect(loop_counter).to eq(4)
            end
          end

          context 'when max duration is not reached' do
            it 'returns value from block' do
              Timecop.freeze(time_start) do
                expect(
                  subject.repeat_until(max_duration: 1) do
                    return_value
                  end
                ).to eq(return_value)
              end
            end
          end
        end

        context 'when max_attempts is provided' do
          context 'when max_attempts is reached' do
            it 'raises an exception' do
              expect do
                Timecop.freeze do
                  subject.repeat_until(max_attempts: 1) do
                    false
                  end
                end
              end.to raise_error(QA::Support::Repeater::RetriesExceededError, "Retry condition not met after 1 attempt")
            end

            it 'ignores duration' do
              loop_counter = 0

              expect(
                Timecop.freeze do
                  subject.repeat_until(max_attempts: 2) do
                    loop_counter += 1
                    Timecop.travel(1.year)

                    if loop_counter > 1
                      return_value
                    else
                      false
                    end
                  end
                end
              ).to eq(return_value)
              expect(loop_counter).to eq(2)
            end
          end

          context 'when max_attempts is not reached' do
            it 'returns value from block' do
              expect(
                Timecop.freeze do
                  subject.repeat_until(max_attempts: 1) do
                    return_value
                  end
                end
              ).to eq(return_value)
            end
          end
        end

        context 'when both max_attempts and max_duration are provided' do
          context 'when max_attempts is reached first' do
            it 'raises an exception' do
              loop_counter = 0
              expect do
                Timecop.freeze do
                  subject.repeat_until(max_attempts: 1, max_duration: 2) do
                    loop_counter += 1
                    Timecop.travel(time_start + loop_counter)
                    false
                  end
                end
              end.to raise_error(QA::Support::Repeater::RetriesExceededError, "Retry condition not met after 1 attempt")
            end
          end

          context 'when max_duration is reached first' do
            it 'raises an exception' do
              loop_counter = 0
              expect do
                Timecop.freeze do
                  subject.repeat_until(max_attempts: 2, max_duration: 1) do
                    loop_counter += 1
                    Timecop.travel(time_start + loop_counter)
                    false
                  end
                end
              end.to raise_error(QA::Support::Repeater::WaitExceededError, "Wait condition not met after 1 second")
            end
          end
        end
      end

      context 'when retry_on_exception is true' do
        context 'when max duration is reached' do
          it 'raises an exception' do
            Timecop.freeze do
              expect do
                subject.repeat_until(max_duration: 1, retry_on_exception: true) do
                  Timecop.travel(2)

                  raise "this should be raised"
                end
              end.to raise_error(RuntimeError, "this should be raised")
            end
          end

          it 'does not raise an exception until max_duration is reached' do
            loop_counter = 0

            Timecop.freeze(time_start) do
              expect do
                subject.repeat_until(max_duration: 2, retry_on_exception: true) do
                  loop_counter += 1
                  Timecop.travel(time_start + loop_counter)

                  raise "this should be raised"
                end
              end.to raise_error(RuntimeError, "this should be raised")
            end
            expect(loop_counter).to eq(2)
          end
        end

        context 'when max duration is not reached' do
          it 'returns value from block' do
            loop_counter = 0

            Timecop.freeze(time_start) do
              expect(
                subject.repeat_until(max_duration: 3, retry_on_exception: true) do
                  loop_counter += 1
                  Timecop.travel(time_start + loop_counter)

                  raise "this should not be raised" if loop_counter == 1

                  return_value
                end
              ).to eq(return_value)
            end
            expect(loop_counter).to eq(2)
          end
        end

        context 'when both max_attempts and max_duration are provided' do
          context 'when max_attempts is reached first' do
            it 'raises an exception' do
              loop_counter = 0
              expect do
                Timecop.freeze do
                  subject.repeat_until(max_attempts: 1, max_duration: 2, retry_on_exception: true) do
                    loop_counter += 1
                    Timecop.travel(time_start + loop_counter)
                    false
                  end
                end
              end.to raise_error(QA::Support::Repeater::RetriesExceededError, "Retry condition not met after 1 attempt")
            end
          end

          context 'when max_duration is reached first' do
            it 'raises an exception' do
              loop_counter = 0
              expect do
                Timecop.freeze do
                  subject.repeat_until(max_attempts: 2, max_duration: 1, retry_on_exception: true) do
                    loop_counter += 1
                    Timecop.travel(time_start + loop_counter)
                    false
                  end
                end
              end.to raise_error(QA::Support::Repeater::WaitExceededError, "Wait condition not met after 1 second")
            end
          end
        end
      end
    end

    context 'when raise_on_failure is false' do
      context 'when retry_on_exception is not provided (default: false)' do
        context 'when max duration is reached' do
          def test_wait
            Timecop.freeze do
              subject.repeat_until(max_duration: 1, raise_on_failure: false) do
                Timecop.travel(2)
                return_value
              end
            end
          end

          it 'does not raise an exception' do
            expect { test_wait }.not_to raise_error
          end

          it 'returns the value from the block' do
            expect(test_wait).to eq(return_value)
          end
        end

        context 'when max duration is not reached' do
          it 'returns the value from the block' do
            Timecop.freeze do
              expect(
                subject.repeat_until(max_duration: 1, raise_on_failure: false) do
                  return_value
                end
              ).to eq(return_value)
            end
          end

          it 'raises an exception' do
            Timecop.freeze do
              expect do
                subject.repeat_until(max_duration: 1, raise_on_failure: false) do
                  raise "this should be raised"
                end
              end.to raise_error(RuntimeError, "this should be raised")
            end
          end
        end

        context 'when both max_attempts and max_duration are provided' do
          shared_examples 'repeat until' do |max_attempts:, max_duration:|
            it "returns when #{max_attempts < max_duration ? 'max_attempts' : 'max_duration'} is reached" do
              loop_counter = 0

              expect(
                Timecop.freeze do
                  subject.repeat_until(max_attempts: max_attempts, max_duration: max_duration, raise_on_failure: false) do
                    loop_counter += 1
                    Timecop.travel(time_start + loop_counter)
                    false
                  end
                end
              ).to eq(false)
              expect(loop_counter).to eq(1)
            end
          end

          context 'when max_attempts is reached first' do
            it_behaves_like 'repeat until', max_attempts: 1, max_duration: 2
          end

          context 'when max_duration is reached first' do
            it_behaves_like 'repeat until', max_attempts: 2, max_duration: 1
          end
        end
      end

      context 'when retry_on_exception is true' do
        context 'when max duration is reached' do
          def test_wait
            Timecop.freeze do
              subject.repeat_until(max_duration: 1, raise_on_failure: false, retry_on_exception: true) do
                Timecop.travel(2)
                return_value
              end
            end
          end

          it 'does not raise an exception' do
            expect { test_wait }.not_to raise_error
          end

          it 'returns the value from the block' do
            expect(test_wait).to eq(return_value)
          end
        end

        context 'when max duration is not reached' do
          before do
            @loop_counter = 0
          end

          def test_wait_with_counter
            Timecop.freeze(time_start) do
              subject.repeat_until(max_duration: 3, raise_on_failure: false, retry_on_exception: true) do
                @loop_counter += 1
                Timecop.travel(time_start + @loop_counter)

                raise "this should not be raised" if @loop_counter == 1

                return_value
              end
            end
          end

          it 'does not raise an exception' do
            expect { test_wait_with_counter }.not_to raise_error
          end

          it 'returns the value from the block' do
            expect(test_wait_with_counter).to eq(return_value)
            expect(@loop_counter).to eq(2)
          end
        end

        context 'when both max_attempts and max_duration are provided' do
          shared_examples 'repeat until' do |max_attempts:, max_duration:|
            it "returns when #{max_attempts < max_duration ? 'max_attempts' : 'max_duration'} is reached" do
              loop_counter = 0

              expect(
                Timecop.freeze do
                  subject.repeat_until(max_attempts: max_attempts, max_duration: max_duration, raise_on_failure: false, retry_on_exception: true) do
                    loop_counter += 1
                    Timecop.travel(time_start + loop_counter)
                    false
                  end
                end
              ).to eq(false)
              expect(loop_counter).to eq(1)
            end
          end

          context 'when max_attempts is reached first' do
            it_behaves_like 'repeat until', max_attempts: 1, max_duration: 2
          end

          context 'when max_duration is reached first' do
            it_behaves_like 'repeat until', max_attempts: 2, max_duration: 1
          end
        end
      end
    end

    it 'logs attempts' do
      attempted = false

      expect do
        subject.repeat_until(max_attempts: 1) do
          unless attempted
            attempted = true
            break false
          end

          true
        end
      end.to output(/Attempt number/).to_stdout_from_any_process
    end

    it 'allows logging to be silenced' do
      attempted = false

      expect do
        subject.repeat_until(max_attempts: 1, log: false) do
          unless attempted
            attempted = true
            break false
          end

          true
        end
      end.not_to output.to_stdout_from_any_process
    end
  end
end