api_matchers 0.6.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +30 -0
  3. data/.gitignore +1 -1
  4. data/Gemfile +1 -1
  5. data/History.markdown +62 -46
  6. data/README.markdown +1070 -114
  7. data/TODO.markdown +39 -3
  8. data/api_matchers.gemspec +13 -6
  9. data/lib/api_matchers/collection/base.rb +65 -0
  10. data/lib/api_matchers/collection/be_sorted_by.rb +97 -0
  11. data/lib/api_matchers/collection/have_json_size.rb +54 -0
  12. data/lib/api_matchers/core/exceptions.rb +5 -0
  13. data/lib/api_matchers/core/find_in_json.rb +57 -28
  14. data/lib/api_matchers/core/http_status_codes.rb +118 -0
  15. data/lib/api_matchers/core/json_path_finder.rb +87 -0
  16. data/lib/api_matchers/core/parser.rb +4 -2
  17. data/lib/api_matchers/core/rspec_matchers.rb +130 -37
  18. data/lib/api_matchers/core/setup.rb +49 -23
  19. data/lib/api_matchers/core/value_normalizer.rb +26 -0
  20. data/lib/api_matchers/error_response/base.rb +77 -0
  21. data/lib/api_matchers/error_response/have_error.rb +69 -0
  22. data/lib/api_matchers/error_response/have_error_on.rb +173 -0
  23. data/lib/api_matchers/hateoas/base.rb +77 -0
  24. data/lib/api_matchers/hateoas/have_link.rb +102 -0
  25. data/lib/api_matchers/headers/base.rb +7 -7
  26. data/lib/api_matchers/headers/be_json.rb +2 -4
  27. data/lib/api_matchers/headers/be_xml.rb +2 -4
  28. data/lib/api_matchers/headers/have_cache_control.rb +90 -0
  29. data/lib/api_matchers/headers/have_cors_headers.rb +102 -0
  30. data/lib/api_matchers/headers/have_header.rb +102 -0
  31. data/lib/api_matchers/http_status/base.rb +48 -0
  32. data/lib/api_matchers/http_status/be_client_error.rb +17 -0
  33. data/lib/api_matchers/http_status/be_forbidden.rb +17 -0
  34. data/lib/api_matchers/http_status/be_no_content.rb +17 -0
  35. data/lib/api_matchers/http_status/be_not_found.rb +17 -0
  36. data/lib/api_matchers/http_status/be_redirect.rb +17 -0
  37. data/lib/api_matchers/http_status/be_server_error.rb +17 -0
  38. data/lib/api_matchers/http_status/be_successful.rb +17 -0
  39. data/lib/api_matchers/http_status/be_unauthorized.rb +17 -0
  40. data/lib/api_matchers/http_status/be_unprocessable.rb +17 -0
  41. data/lib/api_matchers/http_status/have_http_status.rb +39 -0
  42. data/lib/api_matchers/json_api/base.rb +83 -0
  43. data/lib/api_matchers/json_api/be_json_api_compliant.rb +158 -0
  44. data/lib/api_matchers/json_api/have_json_api_attributes.rb +62 -0
  45. data/lib/api_matchers/json_api/have_json_api_data.rb +110 -0
  46. data/lib/api_matchers/json_api/have_json_api_relationships.rb +62 -0
  47. data/lib/api_matchers/json_structure/base.rb +65 -0
  48. data/lib/api_matchers/json_structure/have_json_keys.rb +55 -0
  49. data/lib/api_matchers/json_structure/have_json_type.rb +72 -0
  50. data/lib/api_matchers/pagination/base.rb +73 -0
  51. data/lib/api_matchers/pagination/be_paginated.rb +73 -0
  52. data/lib/api_matchers/pagination/have_pagination_links.rb +74 -0
  53. data/lib/api_matchers/pagination/have_total_count.rb +77 -0
  54. data/lib/api_matchers/response_body/base.rb +10 -9
  55. data/lib/api_matchers/response_body/have_json.rb +2 -4
  56. data/lib/api_matchers/response_body/have_json_node.rb +45 -1
  57. data/lib/api_matchers/response_body/have_node.rb +2 -0
  58. data/lib/api_matchers/response_body/have_xml_node.rb +13 -14
  59. data/lib/api_matchers/response_body/match_json_schema.rb +89 -0
  60. data/lib/api_matchers/version.rb +3 -1
  61. data/lib/api_matchers.rb +75 -14
  62. data/spec/api_matchers/collection/be_sorted_by_spec.rb +110 -0
  63. data/spec/api_matchers/collection/have_json_size_spec.rb +101 -0
  64. data/spec/api_matchers/error_response/have_error_on_spec.rb +123 -0
  65. data/spec/api_matchers/error_response/have_error_spec.rb +108 -0
  66. data/spec/api_matchers/hateoas/have_link_spec.rb +105 -0
  67. data/spec/api_matchers/headers/base_spec.rb +8 -3
  68. data/spec/api_matchers/headers/be_json_spec.rb +1 -1
  69. data/spec/api_matchers/headers/be_xml_spec.rb +1 -1
  70. data/spec/api_matchers/headers/have_cache_control_spec.rb +102 -0
  71. data/spec/api_matchers/headers/have_cors_headers_spec.rb +74 -0
  72. data/spec/api_matchers/headers/have_header_spec.rb +88 -0
  73. data/spec/api_matchers/http_status/be_client_error_spec.rb +53 -0
  74. data/spec/api_matchers/http_status/be_forbidden_spec.rb +33 -0
  75. data/spec/api_matchers/http_status/be_no_content_spec.rb +33 -0
  76. data/spec/api_matchers/http_status/be_not_found_spec.rb +39 -0
  77. data/spec/api_matchers/http_status/be_redirect_spec.rb +55 -0
  78. data/spec/api_matchers/http_status/be_server_error_spec.rb +49 -0
  79. data/spec/api_matchers/http_status/be_successful_spec.rb +78 -0
  80. data/spec/api_matchers/http_status/be_unauthorized_spec.rb +33 -0
  81. data/spec/api_matchers/http_status/be_unprocessable_spec.rb +39 -0
  82. data/spec/api_matchers/http_status/have_http_status_spec.rb +81 -0
  83. data/spec/api_matchers/json_api/be_json_api_compliant_spec.rb +109 -0
  84. data/spec/api_matchers/json_api/have_json_api_attributes_spec.rb +61 -0
  85. data/spec/api_matchers/json_api/have_json_api_data_spec.rb +95 -0
  86. data/spec/api_matchers/json_api/have_json_api_relationships_spec.rb +61 -0
  87. data/spec/api_matchers/json_structure/have_json_keys_spec.rb +81 -0
  88. data/spec/api_matchers/json_structure/have_json_type_spec.rb +134 -0
  89. data/spec/api_matchers/pagination/be_paginated_spec.rb +95 -0
  90. data/spec/api_matchers/pagination/have_pagination_links_spec.rb +80 -0
  91. data/spec/api_matchers/pagination/have_total_count_spec.rb +85 -0
  92. data/spec/api_matchers/response_body/base_spec.rb +15 -7
  93. data/spec/api_matchers/response_body/have_json_node_spec.rb +57 -0
  94. data/spec/api_matchers/response_body/match_json_schema_spec.rb +86 -0
  95. metadata +154 -48
  96. data/.rvmrc.example +0 -1
  97. data/.travis.yml +0 -12
  98. data/lib/api_matchers/http_status_code/base.rb +0 -32
  99. data/lib/api_matchers/http_status_code/be_bad_request.rb +0 -25
  100. data/lib/api_matchers/http_status_code/be_forbidden.rb +0 -21
  101. data/lib/api_matchers/http_status_code/be_internal_server_error.rb +0 -25
  102. data/lib/api_matchers/http_status_code/be_not_found.rb +0 -25
  103. data/lib/api_matchers/http_status_code/be_ok.rb +0 -25
  104. data/lib/api_matchers/http_status_code/be_unauthorized.rb +0 -25
  105. data/lib/api_matchers/http_status_code/be_unprocessable_entity.rb +0 -25
  106. data/lib/api_matchers/http_status_code/create_resource.rb +0 -25
  107. data/spec/api_matchers/http_status_code/base_spec.rb +0 -12
  108. data/spec/api_matchers/http_status_code/be_bad_request_spec.rb +0 -49
  109. data/spec/api_matchers/http_status_code/be_forbidden_spec.rb +0 -49
  110. data/spec/api_matchers/http_status_code/be_internal_server_error_spec.rb +0 -49
  111. data/spec/api_matchers/http_status_code/be_not_found_spec.rb +0 -49
  112. data/spec/api_matchers/http_status_code/be_ok_spec.rb +0 -49
  113. data/spec/api_matchers/http_status_code/be_unauthorized_spec.rb +0 -49
  114. data/spec/api_matchers/http_status_code/be_unprocessable_entity_spec.rb +0 -27
  115. data/spec/api_matchers/http_status_code/create_resource_spec.rb +0 -49
@@ -1,69 +1,162 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module RSpecMatchers
3
- def be_bad_request
4
- ::APIMatchers::HTTPStatusCode::BeBadRequest.new(::APIMatchers::Core::Setup)
5
+ # Content Type Matchers
6
+ def be_xml
7
+ ::APIMatchers::Headers::BeXML.new
5
8
  end
6
- alias :be_a_bad_request :be_bad_request
9
+ alias :be_in_xml :be_xml
7
10
 
8
- def be_not_found
9
- ::APIMatchers::HTTPStatusCode::BeNotFound.new(::APIMatchers::Core::Setup)
11
+ def be_json
12
+ ::APIMatchers::Headers::BeJSON.new
10
13
  end
14
+ alias :be_in_json :be_json
15
+ alias :be_a_json :be_json
11
16
 
12
- def be_internal_server_error
13
- ::APIMatchers::HTTPStatusCode::BeInternalServerError.new(::APIMatchers::Core::Setup)
17
+ # Response Body Matchers
18
+ def have_json_node(expected_node)
19
+ ::APIMatchers::ResponseBody::HaveJsonNode.new(expected_node: expected_node)
20
+ end
21
+
22
+ def have_xml_node(expected_node)
23
+ ::APIMatchers::ResponseBody::HaveXmlNode.new(expected_node: expected_node)
24
+ end
25
+
26
+ def have_json(expected_json)
27
+ ::APIMatchers::ResponseBody::HaveJson.new(expected_json)
28
+ end
29
+
30
+ def have_node(expected_node)
31
+ if ::APIMatchers::Core::Setup.have_node_matcher.equal?(:json)
32
+ have_json_node(expected_node)
33
+ else
34
+ have_xml_node(expected_node)
35
+ end
36
+ end
37
+
38
+ def match_json_schema(schema)
39
+ ::APIMatchers::ResponseBody::MatchJsonSchema.new(schema: schema)
40
+ end
41
+
42
+ # HTTP Status Matchers
43
+ def have_http_status(expected)
44
+ ::APIMatchers::HTTPStatus::HaveHttpStatus.new(expected)
45
+ end
46
+
47
+ def be_successful
48
+ ::APIMatchers::HTTPStatus::BeSuccessful.new
49
+ end
50
+ alias :be_success :be_successful
51
+
52
+ def be_redirect
53
+ ::APIMatchers::HTTPStatus::BeRedirect.new
54
+ end
55
+ alias :be_redirection :be_redirect
56
+
57
+ def be_client_error
58
+ ::APIMatchers::HTTPStatus::BeClientError.new
59
+ end
60
+
61
+ def be_server_error
62
+ ::APIMatchers::HTTPStatus::BeServerError.new
63
+ end
64
+
65
+ def be_not_found
66
+ ::APIMatchers::HTTPStatus::BeNotFound.new
14
67
  end
15
- alias :be_an_internal_server_error :be_internal_server_error
16
68
 
17
69
  def be_unauthorized
18
- ::APIMatchers::HTTPStatusCode::BeUnauthorized.new(::APIMatchers::Core::Setup)
70
+ ::APIMatchers::HTTPStatus::BeUnauthorized.new
19
71
  end
20
72
 
21
73
  def be_forbidden
22
- ::APIMatchers::HTTPStatusCode::BeForbidden.new(::APIMatchers::Core::Setup)
74
+ ::APIMatchers::HTTPStatus::BeForbidden.new
23
75
  end
24
76
 
25
- def be_ok
26
- ::APIMatchers::HTTPStatusCode::BeOk.new(::APIMatchers::Core::Setup)
77
+ def be_unprocessable
78
+ ::APIMatchers::HTTPStatus::BeUnprocessable.new
27
79
  end
80
+ alias :be_unprocessable_entity :be_unprocessable
28
81
 
29
- def be_unprocessable_entity
30
- ::APIMatchers::HTTPStatusCode::BeUnprocessableEntity.new(::APIMatchers::Core::Setup)
82
+ def be_no_content
83
+ ::APIMatchers::HTTPStatus::BeNoContent.new
31
84
  end
32
85
 
33
- def create_resource
34
- ::APIMatchers::HTTPStatusCode::CreateResource.new(::APIMatchers::Core::Setup)
86
+ # JSON Structure Matchers
87
+ def have_json_keys(*keys)
88
+ ::APIMatchers::JsonStructure::HaveJsonKeys.new(*keys)
35
89
  end
36
- alias :created_resource :create_resource
37
90
 
38
- def be_xml
39
- ::APIMatchers::Headers::BeXML.new(::APIMatchers::Core::Setup)
91
+ def have_json_type(expected_type)
92
+ ::APIMatchers::JsonStructure::HaveJsonType.new(expected_type)
40
93
  end
41
- alias :be_in_xml :be_xml
42
94
 
43
- def be_json
44
- ::APIMatchers::Headers::BeJSON.new(::APIMatchers::Core::Setup)
95
+ # Collection Matchers
96
+ def have_json_size(expected_size)
97
+ ::APIMatchers::Collection::HaveJsonSize.new(expected_size)
45
98
  end
46
- alias :be_in_json :be_json
47
- alias :be_a_json :be_json
48
99
 
49
- def have_json_node(expected_node)
50
- ::APIMatchers::ResponseBody::HaveJsonNode.new(expected_node: expected_node, setup: ::APIMatchers::Core::Setup)
100
+ def be_sorted_by(field)
101
+ ::APIMatchers::Collection::BeSortedBy.new(field)
51
102
  end
52
103
 
53
- def have_xml_node(expected_node)
54
- ::APIMatchers::ResponseBody::HaveXmlNode.new(expected_node: expected_node, setup: ::APIMatchers::Core::Setup)
104
+ # Header Matchers
105
+ def have_header(header_name)
106
+ ::APIMatchers::Headers::HaveHeader.new(header_name)
55
107
  end
56
108
 
57
- def have_json(expected_json)
58
- ::APIMatchers::ResponseBody::HaveJson.new(expected_json)
109
+ def have_cors_headers
110
+ ::APIMatchers::Headers::HaveCorsHeaders.new
59
111
  end
60
112
 
61
- def have_node(expected_node)
62
- if ::APIMatchers::Core::Setup.have_node_matcher.equal?(:json)
63
- have_json_node(expected_node)
64
- else
65
- have_xml_node(expected_node)
66
- end
113
+ def have_cache_control(*directives)
114
+ ::APIMatchers::Headers::HaveCacheControl.new(*directives)
115
+ end
116
+
117
+ # Pagination Matchers
118
+ def be_paginated
119
+ ::APIMatchers::Pagination::BePaginated.new
120
+ end
121
+
122
+ def have_pagination_links(*link_types)
123
+ ::APIMatchers::Pagination::HavePaginationLinks.new(*link_types)
124
+ end
125
+
126
+ def have_total_count(expected_count)
127
+ ::APIMatchers::Pagination::HaveTotalCount.new(expected_count)
128
+ end
129
+
130
+ # Error Response Matchers
131
+ def have_error
132
+ ::APIMatchers::ErrorResponse::HaveError.new
133
+ end
134
+ alias :have_errors :have_error
135
+
136
+ def have_error_on(field)
137
+ ::APIMatchers::ErrorResponse::HaveErrorOn.new(field)
138
+ end
139
+
140
+ # JSON:API Matchers
141
+ def be_json_api_compliant
142
+ ::APIMatchers::JsonApi::BeJsonApiCompliant.new
143
+ end
144
+
145
+ def have_json_api_data
146
+ ::APIMatchers::JsonApi::HaveJsonApiData.new
147
+ end
148
+
149
+ def have_json_api_attributes(*attributes)
150
+ ::APIMatchers::JsonApi::HaveJsonApiAttributes.new(*attributes)
151
+ end
152
+
153
+ def have_json_api_relationships(*relationships)
154
+ ::APIMatchers::JsonApi::HaveJsonApiRelationships.new(*relationships)
155
+ end
156
+
157
+ # HATEOAS Matchers
158
+ def have_link(rel)
159
+ ::APIMatchers::Hateoas::HaveLink.new(rel)
67
160
  end
68
161
  end
69
- end
162
+ end
@@ -1,28 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module Core
3
5
  class Setup
4
- # The http status method that will be called when you call http status matchers
5
- #
6
- # ==== Examples
7
- #
8
- # response.status.should create_resource
9
- # response.status.should be_bad_request
10
- # response.status.should be_unauthorized
11
- #
12
- # # Instead calling #status everytime, you can configure:
13
- #
14
- # APIMatchers.setup do |config|
15
- # config.http_status_method = :status
16
- # end
17
- #
18
- # Then:
19
- #
20
- # response.should create_resource
21
- # response.should be_bad_request
22
- # response.should be_unauthorized
23
- #
24
- cattr_accessor :http_status_method
25
-
26
6
  # The response body method that will be called when you call the have_node matchers
27
7
  #
28
8
  # ==== Examples
@@ -34,7 +14,7 @@ module APIMatchers
34
14
  # # Instead calling #body everytime, you can configure:
35
15
  #
36
16
  # APIMatchers.setup do |config|
37
- # config.http_status_method = :body
17
+ # config.response_body_method = :body
38
18
  # end
39
19
  #
40
20
  # Then:
@@ -75,6 +55,52 @@ module APIMatchers
75
55
  #
76
56
  cattr_accessor :header_method
77
57
  cattr_accessor :header_content_type_key
58
+
59
+ # HTTP status method - for extracting status codes from response objects
60
+ #
61
+ # ==== Examples
62
+ #
63
+ # APIMatchers.setup do |config|
64
+ # config.http_status_method = :status # or :code, :status_code
65
+ # end
66
+ #
67
+ cattr_accessor :http_status_method
68
+
69
+ # Pagination configuration
70
+ #
71
+ # ==== Examples
72
+ #
73
+ # APIMatchers.setup do |config|
74
+ # config.pagination_meta_path = 'meta' # path to pagination metadata
75
+ # config.pagination_links_path = 'links' # path to pagination links
76
+ # end
77
+ #
78
+ cattr_accessor :pagination_meta_path
79
+ cattr_accessor :pagination_links_path
80
+
81
+ # Error response configuration
82
+ #
83
+ # ==== Examples
84
+ #
85
+ # APIMatchers.setup do |config|
86
+ # config.errors_path = 'errors' # path to errors array
87
+ # config.error_message_key = 'message' # key for error message
88
+ # config.error_field_key = 'field' # key for error field name
89
+ # end
90
+ #
91
+ cattr_accessor :errors_path
92
+ cattr_accessor :error_message_key
93
+ cattr_accessor :error_field_key
94
+
95
+ # HATEOAS links configuration
96
+ #
97
+ # ==== Examples
98
+ #
99
+ # APIMatchers.setup do |config|
100
+ # config.links_path = '_links' # path to HATEOAS links (HAL style)
101
+ # end
102
+ #
103
+ cattr_accessor :links_path
78
104
  end
79
105
  end
80
106
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Core
5
+ class ValueNormalizer
6
+ def self.normalize(value)
7
+ new(value).normalize
8
+ end
9
+
10
+ def initialize(value)
11
+ @value = value
12
+ end
13
+
14
+ def normalize
15
+ case @value
16
+ when DateTime, Date
17
+ @value.to_s
18
+ when Time
19
+ @value.to_datetime.to_s
20
+ else
21
+ @value
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module APIMatchers
6
+ module ErrorResponse
7
+ class Base
8
+ attr_reader :actual
9
+
10
+ def setup
11
+ ::APIMatchers::Core::Setup
12
+ end
13
+
14
+ def matches?(actual)
15
+ @actual = actual
16
+ perform_match
17
+ end
18
+
19
+ def failure_message
20
+ raise NotImplementedError, "Subclasses must implement #failure_message"
21
+ end
22
+
23
+ def failure_message_when_negated
24
+ raise NotImplementedError, "Subclasses must implement #failure_message_when_negated"
25
+ end
26
+
27
+ protected
28
+
29
+ def perform_match
30
+ raise NotImplementedError, "Subclasses must implement #perform_match"
31
+ end
32
+
33
+ def response_body
34
+ if setup.response_body_method.present?
35
+ @actual.send(setup.response_body_method)
36
+ else
37
+ @actual
38
+ end
39
+ end
40
+
41
+ def parsed_json
42
+ @parsed_json ||= begin
43
+ body = response_body
44
+ case body
45
+ when Hash, Array
46
+ body
47
+ when String
48
+ JSON.parse(body)
49
+ else
50
+ raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{body}'"
51
+ end
52
+ end
53
+ rescue JSON::ParserError
54
+ raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{response_body}'"
55
+ end
56
+
57
+ def json_at_path(path)
58
+ return parsed_json if path.nil? || path.empty?
59
+
60
+ finder = ::APIMatchers::Core::JsonPathFinder.new(parsed_json)
61
+ finder.find(path)
62
+ end
63
+
64
+ def errors_path
65
+ setup.errors_path || 'errors'
66
+ end
67
+
68
+ def error_message_key
69
+ setup.error_message_key || 'message'
70
+ end
71
+
72
+ def error_field_key
73
+ setup.error_field_key || 'field'
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module ErrorResponse
5
+ class HaveError < Base
6
+ COMMON_ERROR_KEYS = %w[error errors message messages].freeze
7
+
8
+ def failure_message
9
+ "expected response to have error(s). " \
10
+ "Looked for keys: #{COMMON_ERROR_KEYS.inspect} and at path '#{errors_path}'. " \
11
+ "Got: #{truncate_json(parsed_json)}"
12
+ end
13
+
14
+ def failure_message_when_negated
15
+ "expected response NOT to have error(s), but errors were found"
16
+ end
17
+
18
+ def description
19
+ "have error(s)"
20
+ end
21
+
22
+ protected
23
+
24
+ def perform_match
25
+ has_errors_at_path? || has_error_keys_at_root?
26
+ end
27
+
28
+ private
29
+
30
+ def has_errors_at_path?
31
+ errors = safe_json_at_path(errors_path)
32
+ errors_present?(errors)
33
+ end
34
+
35
+ def has_error_keys_at_root?
36
+ return false unless parsed_json.is_a?(Hash)
37
+
38
+ COMMON_ERROR_KEYS.any? do |key|
39
+ value = parsed_json[key] || parsed_json[key.to_sym]
40
+ errors_present?(value)
41
+ end
42
+ end
43
+
44
+ def errors_present?(value)
45
+ case value
46
+ when Array
47
+ value.any?
48
+ when Hash
49
+ value.any?
50
+ when String
51
+ value.present?
52
+ else
53
+ false
54
+ end
55
+ end
56
+
57
+ def safe_json_at_path(path)
58
+ json_at_path(path)
59
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
60
+ nil
61
+ end
62
+
63
+ def truncate_json(json)
64
+ str = json.to_json
65
+ str.length > 200 ? "#{str[0..200]}..." : str
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module ErrorResponse
5
+ class HaveErrorOn < Base
6
+ def initialize(field)
7
+ @field = field.to_s
8
+ @expected_message = nil
9
+ @message_pattern = nil
10
+ end
11
+
12
+ def with_message(message)
13
+ @expected_message = message
14
+ self
15
+ end
16
+
17
+ def matching(pattern)
18
+ @message_pattern = pattern
19
+ self
20
+ end
21
+
22
+ def failure_message
23
+ errors = find_errors
24
+
25
+ if errors.nil? || errors.empty?
26
+ "expected response to have error on '#{@field}', but no errors were found. " \
27
+ "Got: #{truncate_json(parsed_json)}"
28
+ elsif !field_has_error?(errors)
29
+ "expected response to have error on '#{@field}'. " \
30
+ "Found errors on: #{error_fields(errors).inspect}"
31
+ elsif @expected_message
32
+ "expected error on '#{@field}' to have message '#{@expected_message}'. " \
33
+ "Got messages: #{field_messages(errors).inspect}"
34
+ elsif @message_pattern
35
+ "expected error on '#{@field}' to match #{@message_pattern.inspect}. " \
36
+ "Got messages: #{field_messages(errors).inspect}"
37
+ else
38
+ "expected response to have error on '#{@field}'"
39
+ end
40
+ end
41
+
42
+ def failure_message_when_negated
43
+ if @expected_message
44
+ "expected NOT to have error on '#{@field}' with message '#{@expected_message}', but it was found"
45
+ elsif @message_pattern
46
+ "expected NOT to have error on '#{@field}' matching #{@message_pattern.inspect}, but it was found"
47
+ else
48
+ "expected NOT to have error on '#{@field}', but it was found"
49
+ end
50
+ end
51
+
52
+ def description
53
+ desc = "have error on '#{@field}'"
54
+ desc += " with message '#{@expected_message}'" if @expected_message
55
+ desc += " matching #{@message_pattern.inspect}" if @message_pattern
56
+ desc
57
+ end
58
+
59
+ protected
60
+
61
+ def perform_match
62
+ errors = find_errors
63
+ return false if errors.nil? || errors.empty?
64
+ return false unless field_has_error?(errors)
65
+
66
+ if @expected_message
67
+ message_matches?(errors, @expected_message)
68
+ elsif @message_pattern
69
+ message_matches_pattern?(errors, @message_pattern)
70
+ else
71
+ true
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def find_errors
78
+ # Try errors path first
79
+ errors = safe_json_at_path(errors_path)
80
+ return errors if errors.is_a?(Array) || errors.is_a?(Hash)
81
+
82
+ # Try root level
83
+ if parsed_json.is_a?(Hash)
84
+ # Rails-style errors: { field: ["message1", "message2"] }
85
+ return parsed_json if parsed_json.key?(@field) || parsed_json.key?(@field.to_sym)
86
+ return parsed_json['errors'] if parsed_json['errors']
87
+ return parsed_json[:errors] if parsed_json[:errors]
88
+ # Check if root has any field-like structure
89
+ return parsed_json if parsed_json.values.any? { |v| v.is_a?(Array) }
90
+ end
91
+
92
+ nil
93
+ end
94
+
95
+ def field_has_error?(errors)
96
+ case errors
97
+ when Array
98
+ errors.any? { |e| error_for_field?(e) }
99
+ when Hash
100
+ # Rails-style: { field: ["message1", "message2"] }
101
+ errors.key?(@field) || errors.key?(@field.to_sym) ||
102
+ # Or hash with field key
103
+ errors.any? { |_, v| v.is_a?(Hash) && error_for_field?(v) }
104
+ else
105
+ false
106
+ end
107
+ end
108
+
109
+ def error_for_field?(error)
110
+ return false unless error.is_a?(Hash)
111
+
112
+ field_key = error_field_key
113
+ field_value = error[field_key] || error[field_key.to_sym] ||
114
+ error['field'] || error[:field] ||
115
+ error['attribute'] || error[:attribute]
116
+
117
+ field_value.to_s == @field
118
+ end
119
+
120
+ def error_fields(errors)
121
+ case errors
122
+ when Array
123
+ errors.filter_map do |e|
124
+ next unless e.is_a?(Hash)
125
+ e[error_field_key] || e[error_field_key.to_sym] ||
126
+ e['field'] || e[:field] ||
127
+ e['attribute'] || e[:attribute]
128
+ end.map(&:to_s).uniq
129
+ when Hash
130
+ errors.keys.map(&:to_s)
131
+ else
132
+ []
133
+ end
134
+ end
135
+
136
+ def field_messages(errors)
137
+ case errors
138
+ when Array
139
+ errors.filter_map do |e|
140
+ next unless e.is_a?(Hash) && error_for_field?(e)
141
+ e[error_message_key] || e[error_message_key.to_sym] ||
142
+ e['message'] || e[:message]
143
+ end
144
+ when Hash
145
+ # Rails-style: { field: ["message1", "message2"] }
146
+ messages = errors[@field] || errors[@field.to_sym]
147
+ messages.is_a?(Array) ? messages : [messages].compact
148
+ else
149
+ []
150
+ end
151
+ end
152
+
153
+ def message_matches?(errors, expected)
154
+ field_messages(errors).any? { |m| m.to_s == expected.to_s }
155
+ end
156
+
157
+ def message_matches_pattern?(errors, pattern)
158
+ field_messages(errors).any? { |m| m.to_s.match?(pattern) }
159
+ end
160
+
161
+ def safe_json_at_path(path)
162
+ json_at_path(path)
163
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
164
+ nil
165
+ end
166
+
167
+ def truncate_json(json)
168
+ str = json.to_json
169
+ str.length > 200 ? "#{str[0..200]}..." : str
170
+ end
171
+ end
172
+ end
173
+ end