Module: RSpec::Signal::Symptoms::HttpStatus

Defined in:
lib/rspec/signal/symptoms/http_status.rb

Overview

An HTTP status the application returned but the spec did not expect.

Clustered on the actual status, because that is the fact with a shared cause: eight specs that each wanted something different and all got 404 are eight symptoms of one broken route. The expected side survives only as this failure's detail line, so a cluster can still show that some examples wanted 200 and others wanted a redirect.

Constant Summary collapse

CODE =

One side of a status mismatch: 404, :not_found, :ok (200).

/:?[\w-]+(?: \(\d{3}\))?/.freeze
PATTERNS =
[
  # rspec-rails `have_http_status(200)` / `have_http_status(:ok)`
  /expected the response to have status code (?<expected>#{CODE}) *,? *but it was (?<actual>#{CODE})/i,
  # rspec-rails `have_http_status(:success)` / `:redirect` / `:error` / `:missing`
  /expected the response to have an? (?<expected>\w+) status code \([^)]*\) *,? *but it was (?<actual>#{CODE})/i, # rubocop:disable Layout/LineLength
  # Rails' own `assert_response`
  /expected response to be a <(?<expected>[^>]+)>, *but was a <(?<actual>[^>]+)>/i,
  # The shorthand several house matchers use
  /expected(?: the)? response status (?:to be )?(?<expected>:?[\w-]+) *,? *(?:but )?got:? *(?<actual>:?[\w-]+)/i
].freeze
NAMES =

Enough of the standard set to name the common ones. An unrecognised code still clusters, it just does not get a friendly name.

{
  "200" => "OK", "201" => "Created", "204" => "No Content", "301" => "Moved Permanently",
  "302" => "Found", "304" => "Not Modified", "400" => "Bad Request", "401" => "Unauthorized",
  "403" => "Forbidden", "404" => "Not Found", "406" => "Not Acceptable", "409" => "Conflict",
  "422" => "Unprocessable Entity", "429" => "Too Many Requests",
  "500" => "Internal Server Error", "502" => "Bad Gateway", "503" => "Service Unavailable"
}.freeze
CODES =
{
  "ok" => "200", "created" => "201", "no_content" => "204", "moved_permanently" => "301",
  "found" => "302", "not_modified" => "304", "bad_request" => "400", "unauthorized" => "401",
  "forbidden" => "403", "not_found" => "404", "not_acceptable" => "406", "conflict" => "409",
  "unprocessable_entity" => "422", "unprocessable_content" => "422", "too_many_requests" => "429",
  "internal_server_error" => "500", "bad_gateway" => "502", "service_unavailable" => "503"
}.freeze
GENERIC_EXPECTATION =
/expected:\s*(?<expected>\d{3})\s+got:\s*(?<actual>\d{3})/i.freeze
STATUS_EXPRESSION =
/
  (?:response\s*\.\s*status|response\s*\.\s*status_code|response\s*\[\s*:status\s*\])
/ix.freeze

Class Method Summary collapse

Class Method Details

.call(failure, text) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
# File 'lib/rspec/signal/symptoms/http_status.rb', line 53

def call(failure, text)
  match = PATTERNS.filter_map { |pattern| pattern.match(text) }.first
  match ||= generic_expectation(failure, text)
  return nil unless match

  actual = code(match[:actual])
  return nil unless actual

  Symptom.new(kind: :http_status, key: "http-status:#{actual}", label: label(actual),
              detail: "expected #{token(match[:expected])}, got #{actual}")
end

.code(raw) ⇒ Object

The numeric code, whether it arrived as a number, a symbol, or both.



81
82
83
84
85
86
# File 'lib/rspec/signal/symptoms/http_status.rb', line 81

def code(raw)
  text = raw.to_s
  return ::Regexp.last_match(1) if text =~ /(\d{3})/

  CODES[text.delete_prefix(":").downcase]
end

.generic_expectation(failure, text) ⇒ Object

RSpec's generic equality matcher does not mention HTTP in its expected/got lines. Only accept it when the captured failing expression explicitly reads a response status, avoiding arbitrary three-digit numeric comparisons.



69
70
71
72
73
# File 'lib/rspec/signal/symptoms/http_status.rb', line 69

def generic_expectation(failure, text)
  return nil unless STATUS_EXPRESSION.match?(failure.message.text.to_s)

  GENERIC_EXPECTATION.match(text)
end

.label(actual) ⇒ Object



75
76
77
78
# File 'lib/rspec/signal/symptoms/http_status.rb', line 75

def label(actual)
  name = NAMES[actual]
  "unexpected #{actual}#{" (#{name})" if name} responses"
end

.token(raw) ⇒ Object

How to word one side of the mismatch: a code where there is one, and otherwise the word the matcher used -- redirect, success.



90
91
92
93
94
95
96
# File 'lib/rspec/signal/symptoms/http_status.rb', line 90

def token(raw)
  text = raw.to_s.strip
  return ::Regexp.last_match(1) if text =~ /(\d{3})/

  bare = text.delete_prefix(":").downcase
  CODES[bare] || bare
end