lib/rspec/rails/matchers/have_http_status.rb



# The following code inspired and modified from Rails' `assert_response`:
#
#   https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/testing/assertions/response.rb#L22-L38
#
# Thank you to all the Rails devs who did the heavy lifting on this!

module RSpec
  module Rails
    module Matchers
      # Namespace for various implementations of `have_http_status`.
      #
      # @api private
      module HaveHttpStatus
        # Instantiates an instance of the proper matcher based on the provided
        # `target`.
        #
        # @param target [Object] expected http status or code
        # @return response matcher instance
        def self.matcher_for_status(target)
          if GenericStatus.valid_statuses.include?(target)
            GenericStatus.new(target)
          elsif Symbol === target
            SymbolicStatus.new(target)
          else
            NumericCode.new(target)
          end
        end

        # @api private
        # Conversion function to coerce the provided object into an
        # `ActionDispatch::TestResponse`.
        #
        # @param obj [Object] object to convert to a response
        # @return [ActionDispatch::TestResponse]
        def as_test_response(obj)
          if ::ActionDispatch::Response === obj || ::Rack::MockResponse === obj
            ::ActionDispatch::TestResponse.from_response(obj)
          elsif ::ActionDispatch::TestResponse === obj
            obj
          elsif obj.respond_to?(:status_code) && obj.respond_to?(:response_headers)
            # Acts As Capybara Session
            # Hack to support `Capybara::Session` without having to load
            # Capybara or catch `NameError`s for the undefined constants
            obj = ActionDispatch::Response.new.tap do |resp|
              resp.status  = obj.status_code
              resp.headers.clear
              resp.headers.merge!(obj.response_headers)
              resp.body    = obj.body
              resp.request = ActionDispatch::Request.new({})
            end
            ::ActionDispatch::TestResponse.from_response(obj)
          else
            raise TypeError, "Invalid response type: #{obj}"
          end
        end
        module_function :as_test_response

        # @return [String, nil] a formatted failure message if
        #   `@invalid_response` is present, `nil` otherwise
        def invalid_response_type_message
          return unless @invalid_response

          "expected a response object, but an instance of " \
          "#{@invalid_response.class} was received"
        end

        # @api private
        # Provides an implementation for `have_http_status` matching against
        # numeric http status codes.
        #
        # Not intended to be instantiated directly.
        #
        # @example
        #   expect(response).to have_http_status(404)
        #
        # @see RSpec::Rails::Matchers#have_http_status
        class NumericCode < RSpec::Rails::Matchers::BaseMatcher
          include HaveHttpStatus

          def initialize(code)
            @expected = code.to_i
            @actual = nil
            @invalid_response = nil
          end

          # @param [Object] response object providing an http code to match
          # @return [Boolean] `true` if the numeric code matched the `response` code
          def matches?(response)
            test_response = as_test_response(response)
            @actual = test_response.response_code.to_i
            expected == @actual
          rescue TypeError => _ignored
            @invalid_response = response
            false
          end

          # @return [String]
          def description
            "respond with numeric status code #{expected}"
          end

          # @return [String] explaining why the match failed
          def failure_message
            invalid_response_type_message ||
            "expected the response to have status code #{expected.inspect}" \
              " but it was #{actual.inspect}"
          end

          # @return [String] explaining why the match failed
          def failure_message_when_negated
            invalid_response_type_message ||
            "expected the response not to have status code " \
            "#{expected.inspect} but it did"
          end
        end

        # @api private
        # Provides an implementation for `have_http_status` matching against
        # Rack symbol http status codes.
        #
        # Not intended to be instantiated directly.
        #
        # @example
        #   expect(response).to have_http_status(:created)
        #
        # @see RSpec::Rails::Matchers#have_http_status
        # @see https://github.com/rack/rack/blob/master/lib/rack/utils.rb `Rack::Utils::SYMBOL_TO_STATUS_CODE`
        class SymbolicStatus < RSpec::Rails::Matchers::BaseMatcher
          include HaveHttpStatus

          def initialize(status)
            @expected_status = status
            @actual = nil
            @invalid_response = nil
            set_expected_code!
          end

          # @param [Object] response object providing an http code to match
          # @return [Boolean] `true` if Rack's associated numeric HTTP code matched
          #   the `response` code
          def matches?(response)
            test_response = as_test_response(response)
            @actual = test_response.response_code
            expected == @actual
          rescue TypeError => _ignored
            @invalid_response = response
            false
          end

          # @return [String]
          def description
            "respond with status code #{pp_expected}"
          end

          # @return [String] explaining why the match failed
          def failure_message
            invalid_response_type_message ||
            "expected the response to have status code #{pp_expected} but it" \
              " was #{pp_actual}"
          end

          # @return [String] explaining why the match failed
          def failure_message_when_negated
            invalid_response_type_message ||
            "expected the response not to have status code #{pp_expected} " \
              "but it did"
          end

          # The initialized expected status symbol
          attr_reader :expected_status
          private :expected_status

        private

          # @return [Symbol] representing the actual http numeric code
          def actual_status
            return unless actual

            @actual_status ||= compute_status_from(actual)
          end

          # Reverse lookup of the Rack status code symbol based on the numeric
          # http code
          #
          # @param code [Fixnum] http status code to look up
          # @return [Symbol] representing the http numeric code
          def compute_status_from(code)
            status, _ = Rack::Utils::SYMBOL_TO_STATUS_CODE.find do |_, c|
              c == code
            end
            status
          end

          # @return [String] pretty format the actual response status
          def pp_actual
            pp_status(actual_status, actual)
          end

          # @return [String] pretty format the expected status and associated code
          def pp_expected
            pp_status(expected_status, expected)
          end

          # @return [String] pretty format the actual response status
          def pp_status(status, code)
            if status
              "#{status.inspect} (#{code})"
            else
              code.to_s
            end
          end

          # Sets `expected` to the numeric http code based on the Rack
          # `expected_status` status
          #
          # @see Rack::Utils::SYMBOL_TO_STATUS_CODE
          # @raise [ArgumentError] if an associated code could not be found
          def set_expected_code!
            @expected ||= Rack::Utils.status_code(expected_status)
          end
        end

        # @api private
        # Provides an implementation for `have_http_status` matching against
        # `ActionDispatch::TestResponse` http status category queries.
        #
        # Not intended to be instantiated directly.
        #
        # @example
        #   expect(response).to have_http_status(:success)
        #   expect(response).to have_http_status(:error)
        #   expect(response).to have_http_status(:missing)
        #   expect(response).to have_http_status(:redirect)
        #
        # @see RSpec::Rails::Matchers#have_http_status
        # @see https://github.com/rails/rails/blob/7-2-stable/actionpack/lib/action_dispatch/testing/test_response.rb `ActionDispatch::TestResponse`
        class GenericStatus < RSpec::Rails::Matchers::BaseMatcher
          include HaveHttpStatus

          # @return [Array<Symbol>] of status codes which represent a HTTP status
          #   code "group"
          # @see https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/testing/test_response.rb `ActionDispatch::TestResponse`
          def self.valid_statuses
            [
              :error, :success, :missing,
              :server_error, :successful, :not_found,
              :redirect
            ]
          end

          def initialize(type)
            unless self.class.valid_statuses.include?(type)
              raise ArgumentError, "Invalid generic HTTP status: #{type.inspect}"
            end

            @expected = type
            @actual = nil
            @invalid_response = nil
          end

          # @return [Boolean] `true` if Rack's associated numeric HTTP code matched
          #   the `response` code or the named response status
          def matches?(response)
            test_response = as_test_response(response)
            @actual = test_response.response_code
            check_expected_status(test_response, expected)
          rescue TypeError => _ignored
            @invalid_response = response
            false
          end

          # @return [String]
          def description
            "respond with #{type_message}"
          end

          # @return [String] explaining why the match failed
          def failure_message
            invalid_response_type_message ||
            "expected the response to have #{type_message} but it was #{actual}"
          end

          # @return [String] explaining why the match failed
          def failure_message_when_negated
            invalid_response_type_message ||
            "expected the response not to have #{type_message} but it was #{actual}"
          end

        protected

          RESPONSE_METHODS = {
            success: 'successful',
            error: 'server_error',
            missing: 'not_found'
          }.freeze

          def check_expected_status(test_response, expected)
            test_response.send(
              "#{RESPONSE_METHODS.fetch(expected, expected)}?")
          end

        private

          # @return [String] formatting the expected status and associated code(s)
          def type_message
            @type_message ||= (expected == :error ? "an error" : "a #{expected}") +
              " status code (#{type_codes})"
          end

          # @return [String] formatting the associated code(s) for the various
          #   status code "groups"
          # @see https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/testing/test_response.rb `ActionDispatch::TestResponse`
          # @see https://github.com/rack/rack/blob/master/lib/rack/response.rb `Rack::Response`
          def type_codes
            # At the time of this commit the most recent version of
            # `ActionDispatch::TestResponse` defines the following aliases:
            #
            #     alias_method :success?,  :successful?
            #     alias_method :missing?,  :not_found?
            #     alias_method :redirect?, :redirection?
            #     alias_method :error?,    :server_error?
            #
            # It's parent `ActionDispatch::Response` includes
            # `Rack::Response::Helpers` which defines the aliased methods as:
            #
            #     def successful?;   status >= 200 && status < 300; end
            #     def redirection?;  status >= 300 && status < 400; end
            #     def server_error?; status >= 500 && status < 600; end
            #     def not_found?;    status == 404;                 end
            #
            # @see https://github.com/rails/rails/blob/ca200378/actionpack/lib/action_dispatch/testing/test_response.rb#L17-L27
            # @see https://github.com/rails/rails/blob/ca200378/actionpack/lib/action_dispatch/http/response.rb#L74
            # @see https://github.com/rack/rack/blob/ce4a3959/lib/rack/response.rb#L119-L122
            @type_codes ||= case expected
                            when :error, :server_error
                              "5xx"
                            when :success, :successful
                              "2xx"
                            when :missing, :not_found
                              "404"
                            when :redirect
                              "3xx"
                            end
          end
        end
      end

      # @api public
      # Passes if `response` has a matching HTTP status code.
      #
      # The following symbolic status codes are allowed:
      #
      # - `Rack::Utils::SYMBOL_TO_STATUS_CODE`
      # - One of the defined `ActionDispatch::TestResponse` aliases:
      #   - `:error`
      #   - `:missing`
      #   - `:redirect`
      #   - `:success`
      #
      # @example Accepts numeric and symbol statuses
      #   expect(response).to have_http_status(404)
      #   expect(response).to have_http_status(:created)
      #   expect(response).to have_http_status(:success)
      #   expect(response).to have_http_status(:error)
      #   expect(response).to have_http_status(:missing)
      #   expect(response).to have_http_status(:redirect)
      #
      # @example Works with standard `response` objects and Capybara's `page`
      #   expect(response).to have_http_status(404)
      #   expect(page).to     have_http_status(:created)
      #
      # @see https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/testing/test_response.rb `ActionDispatch::TestResponse`
      # @see https://github.com/rack/rack/blob/master/lib/rack/utils.rb `Rack::Utils::SYMBOL_TO_STATUS_CODE`
      def have_http_status(target)
        raise ArgumentError, "Invalid HTTP status: nil" unless target

        HaveHttpStatus.matcher_for_status(target)
      end
    end
  end
end