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