lib/rspec/rails/matchers/active_job.rb



require "active_job/base"
require "active_job/arguments"

module RSpec
  module Rails
    module Matchers
      # Namespace for various implementations of ActiveJob features
      #
      # @api private
      module ActiveJob
        # rubocop: disable Metrics/ClassLength
        # @private
        class Base < RSpec::Rails::Matchers::BaseMatcher
          def initialize
            @args = []
            @queue = nil
            @at = nil
            @block = proc { }
            set_expected_number(:exactly, 1)
          end

          def with(*args, &block)
            @args = args
            @block = block if block.present?
            self
          end

          def on_queue(queue)
            @queue = queue.to_s
            self
          end

          def at(time_or_date)
            case time_or_date
            when Time then @at = Time.at(time_or_date.to_f)
            else
              @at = time_or_date
            end
            self
          end

          def exactly(count)
            set_expected_number(:exactly, count)
            self
          end

          def at_least(count)
            set_expected_number(:at_least, count)
            self
          end

          def at_most(count)
            set_expected_number(:at_most, count)
            self
          end

          def times
            self
          end

          def once
            exactly(:once)
          end

          def twice
            exactly(:twice)
          end

          def thrice
            exactly(:thrice)
          end

          def failure_message
            "expected to #{self.class::FAILURE_MESSAGE_EXPECTATION_ACTION} #{base_message}".tap do |msg|
              if @unmatching_jobs.any?
                msg << "\nQueued jobs:"
                @unmatching_jobs.each do |job|
                  msg << "\n  #{base_job_message(job)}"
                end
              end
            end
          end

          def failure_message_when_negated
            "expected not to #{self.class::FAILURE_MESSAGE_EXPECTATION_ACTION} #{base_message}"
          end

          def message_expectation_modifier
            case @expectation_type
            when :exactly then "exactly"
            when :at_most then "at most"
            when :at_least then "at least"
            end
          end

          def supports_block_expectations?
            true
          end

        private

          def check(jobs)
            @matching_jobs, @unmatching_jobs = jobs.partition do |job|
              if job_match?(job) && arguments_match?(job) && queue_match?(job) && at_match?(job)
                args = deserialize_arguments(job)
                @block.call(*args)
                true
              else
                false
              end
            end
            @matching_jobs_count = @matching_jobs.size

            case @expectation_type
            when :exactly then @expected_number == @matching_jobs_count
            when :at_most then @expected_number >= @matching_jobs_count
            when :at_least then @expected_number <= @matching_jobs_count
            end
          end

          def base_message
            "#{message_expectation_modifier} #{@expected_number} jobs,".tap do |msg|
              msg << " with #{@args}," if @args.any?
              msg << " on queue #{@queue}," if @queue
              msg << " at #{@at.inspect}," if @at
              msg << " but #{self.class::MESSAGE_EXPECTATION_ACTION} #{@matching_jobs_count}"
            end
          end

          def base_job_message(job)
            msg_parts = []
            msg_parts << "with #{deserialize_arguments(job)}" if job[:args].any?
            msg_parts << "on queue #{job[:queue]}" if job[:queue]
            msg_parts << "at #{Time.at(job[:at])}" if job[:at]

            "#{job[:job].name} job".tap do |msg|
              msg << " #{msg_parts.join(', ')}" if msg_parts.any?
            end
          end

          def job_match?(job)
            @job ? @job == job[:job] : true
          end

          def arguments_match?(job)
            if @args.any?
              args = serialize_and_deserialize_arguments(@args)
              deserialized_args = deserialize_arguments(job)
              RSpec::Mocks::ArgumentListMatcher.new(*args).args_match?(*deserialized_args)
            else
              true
            end
          end

          def queue_match?(job)
            return true unless @queue

            @queue == job[:queue]
          end

          def at_match?(job)
            return true unless @at
            return job[:at].nil? if @at == :no_wait
            return false unless job[:at]

            scheduled_at = Time.at(job[:at])
            values_match?(@at, scheduled_at) || check_for_inprecise_value(scheduled_at)
          end

          def check_for_inprecise_value(scheduled_at)
            return unless Time === @at && values_match?(@at.change(usec: 0), scheduled_at)

            RSpec.warn_with((<<-WARNING).gsub(/^\s+\|/, '').chomp)
            |[WARNING] Your expected `at(...)` value does not match the job scheduled_at value
            |unless microseconds are removed. This precision error often occurs when checking
            |values against `Time.current` / `Time.now` which have usec precision, but Rails
            |uses `n.seconds.from_now` internally which has a usec count of `0`.
            |
            |Use `change(usec: 0)` to correct these values. For example:
            |
            |`Time.current.change(usec: 0)`
            |
            |Note: RSpec cannot do this for you because jobs can be scheduled with usec
            |precision and we do not know whether it is on purpose or not.
            |
            |
            WARNING
            false
          end

          def set_expected_number(relativity, count)
            @expectation_type = relativity
            @expected_number = case count
                               when :once then 1
                               when :twice then 2
                               when :thrice then 3
                               else Integer(count)
                               end
          end

          def serialize_and_deserialize_arguments(args)
            serialized = ::ActiveJob::Arguments.serialize(args)
            ::ActiveJob::Arguments.deserialize(serialized)
          rescue ::ActiveJob::SerializationError
            args
          end

          def deserialize_arguments(job)
            ::ActiveJob::Arguments.deserialize(job[:args])
          rescue ::ActiveJob::DeserializationError
            job[:args]
          end

          def queue_adapter
            ::ActiveJob::Base.queue_adapter
          end
        end
        # rubocop: enable Metrics/ClassLength

        # @private
        class HaveEnqueuedJob < Base
          FAILURE_MESSAGE_EXPECTATION_ACTION = 'enqueue'.freeze
          MESSAGE_EXPECTATION_ACTION = 'enqueued'.freeze

          def initialize(job)
            super()
            @job = job
          end

          def matches?(proc)
            raise ArgumentError, "have_enqueued_job and enqueue_job only support block expectations" unless Proc === proc

            original_enqueued_jobs = Set.new(queue_adapter.enqueued_jobs)
            proc.call
            enqueued_jobs = Set.new(queue_adapter.enqueued_jobs)

            check(enqueued_jobs - original_enqueued_jobs)
          end

          def does_not_match?(proc)
            set_expected_number(:at_least, 1)

            !matches?(proc)
          end
        end

        # @private
        class HaveBeenEnqueued < Base
          FAILURE_MESSAGE_EXPECTATION_ACTION = 'enqueue'.freeze
          MESSAGE_EXPECTATION_ACTION = 'enqueued'.freeze

          def matches?(job)
            @job = job
            check(queue_adapter.enqueued_jobs)
          end

          def does_not_match?(proc)
            set_expected_number(:at_least, 1)

            !matches?(proc)
          end
        end

        # @private
        class HavePerformedJob < Base
          FAILURE_MESSAGE_EXPECTATION_ACTION = 'perform'.freeze
          MESSAGE_EXPECTATION_ACTION = 'performed'.freeze

          def initialize(job)
            super()
            @job = job
          end

          def matches?(proc)
            raise ArgumentError, "have_performed_job only supports block expectations" unless Proc === proc

            original_performed_jobs_count = queue_adapter.performed_jobs.count
            proc.call
            in_block_jobs = queue_adapter.performed_jobs.drop(original_performed_jobs_count)

            check(in_block_jobs)
          end
        end

        # @private
        class HaveBeenPerformed < Base
          FAILURE_MESSAGE_EXPECTATION_ACTION = 'perform'.freeze
          MESSAGE_EXPECTATION_ACTION = 'performed'.freeze

          def matches?(job)
            @job = job
            check(queue_adapter.performed_jobs)
          end
        end
      end

      # @api public
      # Passes if a job has been enqueued inside block. May chain at_least, at_most or exactly to specify a number of times.
      #
      # @example
      #     expect {
      #       HeavyLiftingJob.perform_later
      #     }.to have_enqueued_job
      #
      #     # Using alias
      #     expect {
      #       HeavyLiftingJob.perform_later
      #     }.to enqueue_job
      #
      #     expect {
      #       HelloJob.perform_later
      #       HeavyLiftingJob.perform_later
      #     }.to have_enqueued_job(HelloJob).exactly(:once)
      #
      #     expect {
      #       3.times { HelloJob.perform_later }
      #     }.to have_enqueued_job(HelloJob).at_least(2).times
      #
      #     expect {
      #       HelloJob.perform_later
      #     }.to have_enqueued_job(HelloJob).at_most(:twice)
      #
      #     expect {
      #       HelloJob.perform_later
      #       HeavyLiftingJob.perform_later
      #     }.to have_enqueued_job(HelloJob).and have_enqueued_job(HeavyLiftingJob)
      #
      #     expect {
      #       HelloJob.set(wait_until: Date.tomorrow.noon, queue: "low").perform_later(42)
      #     }.to have_enqueued_job.with(42).on_queue("low").at(Date.tomorrow.noon)
      #
      #     expect {
      #       HelloJob.set(queue: "low").perform_later(42)
      #     }.to have_enqueued_job.with(42).on_queue("low").at(:no_wait)
      #
      #     expect {
      #       HelloJob.perform_later('rspec_rails', 'rails', 42)
      #     }.to have_enqueued_job.with { |from, to, times|
      #       # Perform more complex argument matching using dynamic arguments
      #       expect(from).to include "_#{to}"
      #     }
      def have_enqueued_job(job = nil)
        check_active_job_adapter
        ActiveJob::HaveEnqueuedJob.new(job)
      end
      alias_method :enqueue_job, :have_enqueued_job

      # @api public
      # Passes if a job has been enqueued. May chain at_least, at_most or exactly to specify a number of times.
      #
      # @example
      #     before { ActiveJob::Base.queue_adapter.enqueued_jobs.clear }
      #
      #     HeavyLiftingJob.perform_later
      #     expect(HeavyLiftingJob).to have_been_enqueued
      #
      #     HelloJob.perform_later
      #     HeavyLiftingJob.perform_later
      #     expect(HeavyLiftingJob).to have_been_enqueued.exactly(:once)
      #
      #     3.times { HelloJob.perform_later }
      #     expect(HelloJob).to have_been_enqueued.at_least(2).times
      #
      #     HelloJob.perform_later
      #     expect(HelloJob).to enqueue_job(HelloJob).at_most(:twice)
      #
      #     HelloJob.perform_later
      #     HeavyLiftingJob.perform_later
      #     expect(HelloJob).to have_been_enqueued
      #     expect(HeavyLiftingJob).to have_been_enqueued
      #
      #     HelloJob.set(wait_until: Date.tomorrow.noon, queue: "low").perform_later(42)
      #     expect(HelloJob).to have_been_enqueued.with(42).on_queue("low").at(Date.tomorrow.noon)
      #
      #     HelloJob.set(queue: "low").perform_later(42)
      #     expect(HelloJob).to have_been_enqueued.with(42).on_queue("low").at(:no_wait)
      def have_been_enqueued
        check_active_job_adapter
        ActiveJob::HaveBeenEnqueued.new
      end

      # @api public
      # Passes if a job has been performed inside block. May chain at_least, at_most or exactly to specify a number of times.
      #
      # @example
      #     expect {
      #       perform_jobs { HeavyLiftingJob.perform_later }
      #     }.to have_performed_job
      #
      #     expect {
      #       perform_jobs {
      #         HelloJob.perform_later
      #         HeavyLiftingJob.perform_later
      #       }
      #     }.to have_performed_job(HelloJob).exactly(:once)
      #
      #     expect {
      #       perform_jobs { 3.times { HelloJob.perform_later } }
      #     }.to have_performed_job(HelloJob).at_least(2).times
      #
      #     expect {
      #       perform_jobs { HelloJob.perform_later }
      #     }.to have_performed_job(HelloJob).at_most(:twice)
      #
      #     expect {
      #       perform_jobs {
      #         HelloJob.perform_later
      #         HeavyLiftingJob.perform_later
      #       }
      #     }.to have_performed_job(HelloJob).and have_performed_job(HeavyLiftingJob)
      #
      #     expect {
      #       perform_jobs {
      #         HelloJob.set(wait_until: Date.tomorrow.noon, queue: "low").perform_later(42)
      #       }
      #     }.to have_performed_job.with(42).on_queue("low").at(Date.tomorrow.noon)
      def have_performed_job(job = nil)
        check_active_job_adapter
        ActiveJob::HavePerformedJob.new(job)
      end
      alias_method :perform_job, :have_performed_job

      # @api public
      # Passes if a job has been performed. May chain at_least, at_most or exactly to specify a number of times.
      #
      # @example
      #     before do
      #       ActiveJob::Base.queue_adapter.performed_jobs.clear
      #       ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true
      #       ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true
      #     end
      #
      #     HeavyLiftingJob.perform_later
      #     expect(HeavyLiftingJob).to have_been_performed
      #
      #     HelloJob.perform_later
      #     HeavyLiftingJob.perform_later
      #     expect(HeavyLiftingJob).to have_been_performed.exactly(:once)
      #
      #     3.times { HelloJob.perform_later }
      #     expect(HelloJob).to have_been_performed.at_least(2).times
      #
      #     HelloJob.perform_later
      #     HeavyLiftingJob.perform_later
      #     expect(HelloJob).to have_been_performed
      #     expect(HeavyLiftingJob).to have_been_performed
      #
      #     HelloJob.set(wait_until: Date.tomorrow.noon, queue: "low").perform_later(42)
      #     expect(HelloJob).to have_been_performed.with(42).on_queue("low").at(Date.tomorrow.noon)
      def have_been_performed
        check_active_job_adapter
        ActiveJob::HaveBeenPerformed.new
      end

    private

      # @private
      def check_active_job_adapter
        return if ::ActiveJob::QueueAdapters::TestAdapter === ::ActiveJob::Base.queue_adapter

        raise StandardError, "To use ActiveJob matchers set `ActiveJob::Base.queue_adapter = :test`"
      end
    end
  end
end