spec_forge 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/.standard.yml +4 -0
- data/CHANGELOG.md +145 -1
- data/README.md +49 -638
- data/flake.lock +3 -3
- data/flake.nix +8 -2
- data/lib/spec_forge/attribute/chainable.rb +208 -20
- data/lib/spec_forge/attribute/factory.rb +141 -12
- data/lib/spec_forge/attribute/faker.rb +64 -15
- data/lib/spec_forge/attribute/global.rb +96 -0
- data/lib/spec_forge/attribute/literal.rb +15 -2
- data/lib/spec_forge/attribute/matcher.rb +188 -13
- data/lib/spec_forge/attribute/parameterized.rb +45 -20
- data/lib/spec_forge/attribute/regex.rb +55 -5
- data/lib/spec_forge/attribute/resolvable.rb +48 -5
- data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
- data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
- data/lib/spec_forge/attribute/store.rb +65 -0
- data/lib/spec_forge/attribute/transform.rb +33 -5
- data/lib/spec_forge/attribute/variable.rb +37 -6
- data/lib/spec_forge/attribute.rb +168 -66
- data/lib/spec_forge/backtrace_formatter.rb +26 -3
- data/lib/spec_forge/callbacks.rb +79 -0
- data/lib/spec_forge/cli/actions.rb +27 -0
- data/lib/spec_forge/cli/command.rb +78 -24
- data/lib/spec_forge/cli/init.rb +11 -1
- data/lib/spec_forge/cli/new.rb +54 -3
- data/lib/spec_forge/cli/run.rb +20 -0
- data/lib/spec_forge/cli.rb +16 -5
- data/lib/spec_forge/configuration.rb +94 -25
- data/lib/spec_forge/context/callbacks.rb +91 -0
- data/lib/spec_forge/context/global.rb +72 -0
- data/lib/spec_forge/context/store.rb +148 -0
- data/lib/spec_forge/context/variables.rb +91 -0
- data/lib/spec_forge/context.rb +36 -0
- data/lib/spec_forge/core_ext/rspec.rb +24 -4
- data/lib/spec_forge/error.rb +267 -113
- data/lib/spec_forge/factory.rb +33 -14
- data/lib/spec_forge/filter.rb +87 -0
- data/lib/spec_forge/forge.rb +170 -0
- data/lib/spec_forge/http/backend.rb +99 -29
- data/lib/spec_forge/http/client.rb +23 -13
- data/lib/spec_forge/http/request.rb +74 -62
- data/lib/spec_forge/http/verb.rb +79 -0
- data/lib/spec_forge/http.rb +105 -0
- data/lib/spec_forge/loader.rb +254 -0
- data/lib/spec_forge/matchers.rb +130 -0
- data/lib/spec_forge/normalizer/configuration.rb +24 -11
- data/lib/spec_forge/normalizer/constraint.rb +22 -9
- data/lib/spec_forge/normalizer/expectation.rb +31 -12
- data/lib/spec_forge/normalizer/factory.rb +24 -11
- data/lib/spec_forge/normalizer/factory_reference.rb +32 -13
- data/lib/spec_forge/normalizer/global_context.rb +88 -0
- data/lib/spec_forge/normalizer/spec.rb +39 -16
- data/lib/spec_forge/normalizer.rb +255 -41
- data/lib/spec_forge/runner/callbacks.rb +246 -0
- data/lib/spec_forge/runner/debug_proxy.rb +213 -0
- data/lib/spec_forge/runner/listener.rb +54 -0
- data/lib/spec_forge/runner/metadata.rb +58 -0
- data/lib/spec_forge/runner/state.rb +99 -0
- data/lib/spec_forge/runner.rb +133 -119
- data/lib/spec_forge/spec/expectation/constraint.rb +95 -20
- data/lib/spec_forge/spec/expectation.rb +43 -51
- data/lib/spec_forge/spec.rb +83 -96
- data/lib/spec_forge/type.rb +36 -4
- data/lib/spec_forge/version.rb +4 -1
- data/lib/spec_forge.rb +161 -76
- metadata +20 -5
- data/spec_forge/factories/user.yml +0 -4
- data/spec_forge/forge_helper.rb +0 -37
- data/spec_forge/specs/users.yml +0 -65
data/lib/spec_forge/http/verb.rb
CHANGED
@@ -2,33 +2,112 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
module HTTP
|
5
|
+
#
|
6
|
+
# Represents an HTTP verb (method)
|
7
|
+
#
|
8
|
+
# This class provides a type-safe way to work with HTTP methods,
|
9
|
+
# with predefined constants for common verbs like GET, POST, etc.
|
10
|
+
#
|
11
|
+
# @example Using predefined verbs
|
12
|
+
# HTTP::Verb::GET # => #<HTTP::Verb::Get @name="GET">
|
13
|
+
# HTTP::Verb::POST # => #<HTTP::Verb::Post @name="POST">
|
14
|
+
#
|
15
|
+
# @example Checking verb types
|
16
|
+
# verb = HTTP::Verb::POST
|
17
|
+
# verb.post? # => true
|
18
|
+
# verb.get? # => false
|
19
|
+
#
|
5
20
|
class Verb < Data.define(:name)
|
21
|
+
#
|
22
|
+
# Represents the HTTP DELETE method
|
23
|
+
#
|
24
|
+
# @return [Delete] A DELETE verb instance
|
25
|
+
#
|
6
26
|
class Delete < Verb
|
7
27
|
def initialize = super(name: "DELETE")
|
8
28
|
end
|
9
29
|
|
30
|
+
#
|
31
|
+
# Represents the HTTP GET method
|
32
|
+
#
|
33
|
+
# @return [Get] A GET verb instance
|
34
|
+
#
|
10
35
|
class Get < Verb
|
11
36
|
def initialize = super(name: "GET")
|
12
37
|
end
|
13
38
|
|
39
|
+
#
|
40
|
+
# Represents the HTTP PATCH method
|
41
|
+
#
|
42
|
+
# @return [Patch] A PATCH verb instance
|
43
|
+
#
|
14
44
|
class Patch < Verb
|
15
45
|
def initialize = super(name: "PATCH")
|
16
46
|
end
|
17
47
|
|
48
|
+
#
|
49
|
+
# Represents the HTTP POST method
|
50
|
+
#
|
51
|
+
# @return [Post] A POST verb instance
|
52
|
+
#
|
18
53
|
class Post < Verb
|
19
54
|
def initialize = super(name: "POST")
|
20
55
|
end
|
21
56
|
|
57
|
+
#
|
58
|
+
# Represents the HTTP PUT method
|
59
|
+
#
|
60
|
+
# @return [Put] A PUT verb instance
|
61
|
+
#
|
22
62
|
class Put < Verb
|
23
63
|
def initialize = super(name: "PUT")
|
24
64
|
end
|
25
65
|
|
66
|
+
#
|
67
|
+
# A predefined DELETE verb instance for HTTP method usage
|
68
|
+
#
|
69
|
+
# @return [Verb::Delete] A singleton instance representing the HTTP DELETE method
|
70
|
+
# @see Verb
|
71
|
+
#
|
26
72
|
DELETE = Delete.new
|
73
|
+
|
74
|
+
#
|
75
|
+
# A predefined GET verb instance for HTTP method usage
|
76
|
+
#
|
77
|
+
# @return [Verb::Get] A singleton instance representing the HTTP GET method
|
78
|
+
# @see Verb
|
79
|
+
#
|
27
80
|
GET = Get.new
|
81
|
+
|
82
|
+
#
|
83
|
+
# A predefined PATCH verb instance for HTTP method usage
|
84
|
+
#
|
85
|
+
# @return [Verb::Patch] A singleton instance representing the HTTP PATCH method
|
86
|
+
# @see Verb
|
87
|
+
#
|
28
88
|
PATCH = Patch.new
|
89
|
+
|
90
|
+
#
|
91
|
+
# A predefined POST verb instance for HTTP method usage
|
92
|
+
#
|
93
|
+
# @return [Verb::Post] A singleton instance representing the HTTP POST method
|
94
|
+
# @see Verb
|
95
|
+
#
|
29
96
|
POST = Post.new
|
97
|
+
|
98
|
+
#
|
99
|
+
# A predefined PUT verb instance for HTTP method usage
|
100
|
+
#
|
101
|
+
# @return [Verb::Put] A singleton instance representing the HTTP PUT method
|
102
|
+
# @see Verb
|
103
|
+
#
|
30
104
|
PUT = Put.new
|
31
105
|
|
106
|
+
#
|
107
|
+
# All HTTP verbs as a lookup hash
|
108
|
+
#
|
109
|
+
# @return [Hash<Symbol, Verb>]
|
110
|
+
#
|
32
111
|
VERBS = {
|
33
112
|
delete: DELETE,
|
34
113
|
get: GET,
|
data/lib/spec_forge/http.rb
CHANGED
@@ -1,5 +1,110 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
module SpecForge
|
4
|
+
#
|
5
|
+
# HTTP module providing request and response handling for API testing
|
6
|
+
#
|
7
|
+
# This module contains the HTTP client, request object, and other components
|
8
|
+
# needed to make API calls and validate responses against expectations.
|
9
|
+
#
|
10
|
+
module HTTP
|
11
|
+
#
|
12
|
+
# A mapping of HTTP status codes to their standard descriptions
|
13
|
+
#
|
14
|
+
# This constant provides a lookup table of common HTTP status codes with their
|
15
|
+
# official descriptions according to HTTP specifications. Used internally
|
16
|
+
# to generate human-readable test output.
|
17
|
+
#
|
18
|
+
# @example Looking up a status code description
|
19
|
+
# HTTP::STATUS_DESCRIPTIONS[200] # => "OK"
|
20
|
+
# HTTP::STATUS_DESCRIPTIONS[404] # => "Not Found"
|
21
|
+
#
|
22
|
+
STATUS_DESCRIPTIONS = {
|
23
|
+
# Success codes
|
24
|
+
200 => "OK",
|
25
|
+
201 => "Created",
|
26
|
+
202 => "Accepted",
|
27
|
+
204 => "No Content",
|
28
|
+
|
29
|
+
# Redirection
|
30
|
+
301 => "Moved Permanently",
|
31
|
+
302 => "Found",
|
32
|
+
304 => "Not Modified",
|
33
|
+
307 => "Temporary Redirect",
|
34
|
+
308 => "Permanent Redirect",
|
35
|
+
|
36
|
+
# Client errors
|
37
|
+
400 => "Bad Request",
|
38
|
+
401 => "Unauthorized",
|
39
|
+
403 => "Forbidden",
|
40
|
+
404 => "Not Found",
|
41
|
+
405 => "Method Not Allowed",
|
42
|
+
406 => "Not Acceptable",
|
43
|
+
407 => "Proxy Authentication Required",
|
44
|
+
409 => "Conflict",
|
45
|
+
410 => "Gone",
|
46
|
+
411 => "Length Required",
|
47
|
+
413 => "Payload Too Large",
|
48
|
+
414 => "URI Too Long",
|
49
|
+
415 => "Unsupported Media Type",
|
50
|
+
421 => "Misdirected Request",
|
51
|
+
422 => "Unprocessable Content",
|
52
|
+
423 => "Locked",
|
53
|
+
424 => "Failed Dependency",
|
54
|
+
428 => "Precondition Required",
|
55
|
+
429 => "Too Many Requests",
|
56
|
+
431 => "Request Header Fields Too Large",
|
57
|
+
|
58
|
+
# Server errors
|
59
|
+
500 => "Internal Server Error",
|
60
|
+
501 => "Not Implemented",
|
61
|
+
502 => "Bad Gateway",
|
62
|
+
503 => "Service Unavailable",
|
63
|
+
504 => "Gateway Timeout"
|
64
|
+
}
|
65
|
+
|
66
|
+
#
|
67
|
+
# Converts an HTTP status code to a human-readable description
|
68
|
+
#
|
69
|
+
# Takes a numeric status code and returns a formatted string containing both
|
70
|
+
# the code and its description. Uses predefined descriptions for common codes,
|
71
|
+
# with fallbacks to category-based descriptions for uncommon codes.
|
72
|
+
#
|
73
|
+
# @param code [Integer, String] The HTTP status code to convert
|
74
|
+
#
|
75
|
+
# @return [String] A formatted description string (e.g., "200 OK", "404 Not Found")
|
76
|
+
#
|
77
|
+
# @example Common status codes
|
78
|
+
# HTTP.status_code_to_description(200) # => "200 OK"
|
79
|
+
# HTTP.status_code_to_description(404) # => "404 Not Found"
|
80
|
+
#
|
81
|
+
# @example Fallback descriptions for uncommon codes
|
82
|
+
# HTTP.status_code_to_description(299) # => "299 Success"
|
83
|
+
# HTTP.status_code_to_description(499) # => "499 Client Error"
|
84
|
+
#
|
85
|
+
def self.status_code_to_description(code)
|
86
|
+
code = code.to_i
|
87
|
+
description = STATUS_DESCRIPTIONS[code]
|
88
|
+
return "#{code} #{description}" if description
|
89
|
+
|
90
|
+
# Fallbacks by range
|
91
|
+
if code >= 100 && code < 200
|
92
|
+
"#{code} Informational"
|
93
|
+
elsif code >= 200 && code < 300
|
94
|
+
"#{code} Success"
|
95
|
+
elsif code >= 300 && code < 400
|
96
|
+
"#{code} Redirection"
|
97
|
+
elsif code >= 400 && code < 500
|
98
|
+
"#{code} Client Error"
|
99
|
+
elsif code >= 500 && code < 600
|
100
|
+
"#{code} Server Error"
|
101
|
+
else
|
102
|
+
code.to_s
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
3
108
|
require_relative "http/backend"
|
4
109
|
require_relative "http/client"
|
5
110
|
require_relative "http/verb"
|
@@ -0,0 +1,254 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
#
|
5
|
+
# Responsible for loading specs from YAML files and converting them to testable objects
|
6
|
+
#
|
7
|
+
# The Loader reads spec files, parses them as YAML, and transforms them into
|
8
|
+
# a structure that can be used to create Forge objects. It also extracts
|
9
|
+
# metadata like line numbers for error reporting.
|
10
|
+
#
|
11
|
+
# @example Loading all specs
|
12
|
+
# specs = Loader.load_from_files
|
13
|
+
#
|
14
|
+
class Loader
|
15
|
+
class << self
|
16
|
+
#
|
17
|
+
# Loads all spec YAML files and transforms them into normalized structures
|
18
|
+
#
|
19
|
+
# @return [Array<Array>] Array of [global, metadata, specs] for each loaded file
|
20
|
+
#
|
21
|
+
def load_from_files
|
22
|
+
# metadata is not normalized because its not user managed
|
23
|
+
load_specs_from_files.map do |global, metadata, specs|
|
24
|
+
global =
|
25
|
+
begin
|
26
|
+
Normalizer.normalize_global_context!(global)
|
27
|
+
rescue => e
|
28
|
+
raise Error::SpecLoadError.new(e, metadata[:relative_path])
|
29
|
+
end
|
30
|
+
|
31
|
+
specs =
|
32
|
+
specs.map do |spec|
|
33
|
+
Normalizer.normalize_spec!(spec, label: "spec \"#{spec[:name]}\"")
|
34
|
+
rescue => e
|
35
|
+
raise Error::SpecLoadError.new(e, metadata[:relative_path], spec:)
|
36
|
+
end
|
37
|
+
|
38
|
+
[global, metadata, specs]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
#
|
43
|
+
# Internal method that handles loading specs from files
|
44
|
+
#
|
45
|
+
# This method coordinates the entire spec loading process by:
|
46
|
+
# 1. Reading files from the specs directory
|
47
|
+
# 2. Parsing them as YAML
|
48
|
+
# 3. Transforming them into the proper structure
|
49
|
+
#
|
50
|
+
# @return [Array<Array>] Array of [global, metadata, specs] for each loaded file
|
51
|
+
#
|
52
|
+
# @private
|
53
|
+
#
|
54
|
+
def load_specs_from_files
|
55
|
+
files = read_from_files
|
56
|
+
parse_and_transform_specs(files)
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# Reads spec files from the spec_forge/specs directory
|
61
|
+
#
|
62
|
+
# @return [Array<Array<String, String>>] Array of [file_path, file_content] pairs
|
63
|
+
#
|
64
|
+
# @private
|
65
|
+
#
|
66
|
+
def read_from_files
|
67
|
+
path = SpecForge.forge_path.join("specs")
|
68
|
+
|
69
|
+
Dir[path.join("**/*.yml")].map do |file_path|
|
70
|
+
[file_path, File.read(file_path)]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
#
|
75
|
+
# Parses YAML content and extracts line numbers for error reporting
|
76
|
+
#
|
77
|
+
# @param files [Array<Array<String, String>>] Array of [file_path, file_content] pairs
|
78
|
+
#
|
79
|
+
# @return [Array<Array>] Array of [global, metadata, specs] for each file
|
80
|
+
#
|
81
|
+
# @private
|
82
|
+
#
|
83
|
+
def parse_and_transform_specs(files)
|
84
|
+
base_path = SpecForge.forge_path.join("specs")
|
85
|
+
|
86
|
+
files.map do |file_path, content|
|
87
|
+
relative_path = Pathname.new(file_path).relative_path_from(base_path)
|
88
|
+
|
89
|
+
hash = YAML.load(content).deep_symbolize_keys
|
90
|
+
|
91
|
+
file_line_numbers = extract_line_numbers(content, hash)
|
92
|
+
|
93
|
+
# Currently, only holds onto global variables
|
94
|
+
global = hash.delete(:global) || {}
|
95
|
+
|
96
|
+
metadata = {
|
97
|
+
file_name: relative_path.basename(".yml").to_s,
|
98
|
+
relative_path: relative_path.to_s,
|
99
|
+
file_path:
|
100
|
+
}
|
101
|
+
|
102
|
+
specs =
|
103
|
+
hash.map do |spec_name, spec_hash|
|
104
|
+
line_number, *expectation_line_numbers = file_line_numbers[spec_name]
|
105
|
+
|
106
|
+
spec_hash[:id] = "spec_#{generate_id(spec_hash)}"
|
107
|
+
spec_hash[:name] = spec_name.to_s
|
108
|
+
spec_hash[:file_path] = metadata[:file_path]
|
109
|
+
spec_hash[:file_name] = metadata[:file_name]
|
110
|
+
spec_hash[:line_number] = line_number
|
111
|
+
|
112
|
+
# Check for expectations instead of defaulting. I want it to error
|
113
|
+
if (expectations = spec_hash[:expectations])
|
114
|
+
expectations.zip(expectation_line_numbers) do |expectation_hash, line_number|
|
115
|
+
expectation_hash[:id] = "expect_#{generate_id(expectation_hash)}"
|
116
|
+
expectation_hash[:name] = build_expectation_name(spec_hash, expectation_hash)
|
117
|
+
expectation_hash[:line_number] = line_number
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
spec_hash
|
122
|
+
end
|
123
|
+
|
124
|
+
[global, metadata, specs]
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
#
|
129
|
+
# Extracts line numbers from each YAML section for error reporting
|
130
|
+
#
|
131
|
+
# @param content [String] The raw file content
|
132
|
+
# @param input_hash [Hash] The parsed YAML structure
|
133
|
+
#
|
134
|
+
# @return [Hash] A mapping of spec names to line numbers
|
135
|
+
#
|
136
|
+
# @private
|
137
|
+
#
|
138
|
+
def extract_line_numbers(content, input_hash)
|
139
|
+
# I hate this code, lol, and it hates me.
|
140
|
+
# I've tried to make it better, I've tried to clean it up, but every time I break it.
|
141
|
+
# If you know how to make this better, please submit a PR and save me.
|
142
|
+
spec_names = input_hash.keys
|
143
|
+
keys = {}
|
144
|
+
|
145
|
+
current_spec_name = nil
|
146
|
+
expectations_line = nil
|
147
|
+
expectations_indent = nil
|
148
|
+
|
149
|
+
content.lines.each_with_index do |line, index|
|
150
|
+
line_number = index + 1
|
151
|
+
clean_line = line.rstrip
|
152
|
+
indentation = line[/^\s*/].size
|
153
|
+
|
154
|
+
# Skip blank lines
|
155
|
+
next if clean_line.empty?
|
156
|
+
|
157
|
+
# Reset on top-level elements
|
158
|
+
if indentation == 0
|
159
|
+
current_spec_name = nil
|
160
|
+
expectations_line = nil
|
161
|
+
expectations_indent = nil
|
162
|
+
|
163
|
+
# Check if this line starts a spec we're interested in
|
164
|
+
spec_names.each do |spec_name|
|
165
|
+
next unless clean_line.start_with?("#{spec_name}:")
|
166
|
+
|
167
|
+
current_spec_name = spec_name
|
168
|
+
keys[current_spec_name] = [line_number]
|
169
|
+
break
|
170
|
+
end
|
171
|
+
|
172
|
+
next
|
173
|
+
end
|
174
|
+
|
175
|
+
# Skip if we're not in a relevant spec
|
176
|
+
next unless current_spec_name
|
177
|
+
|
178
|
+
# Found expectations section
|
179
|
+
if clean_line.match?(/^[^#]\s*expectations:/i)
|
180
|
+
expectations_line = line_number
|
181
|
+
expectations_indent = indentation
|
182
|
+
next
|
183
|
+
end
|
184
|
+
|
185
|
+
# Found an expectation item
|
186
|
+
if expectations_line && clean_line.start_with?("#{" " * expectations_indent}- ")
|
187
|
+
keys[current_spec_name] << line_number
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
keys
|
192
|
+
end
|
193
|
+
|
194
|
+
#
|
195
|
+
# Generates a unique ID for an object based on hash and object_id
|
196
|
+
#
|
197
|
+
# @param object [Object] The object to generate an ID for
|
198
|
+
#
|
199
|
+
# @return [String] A unique ID string
|
200
|
+
#
|
201
|
+
# @private
|
202
|
+
#
|
203
|
+
def generate_id(object)
|
204
|
+
"#{object.hash.abs.to_s(36)}_#{object.object_id.to_s(36)}"
|
205
|
+
end
|
206
|
+
|
207
|
+
#
|
208
|
+
# Builds a name for an expectation based on HTTP verb, URL, and optional name
|
209
|
+
#
|
210
|
+
# @param spec_hash [Hash] The spec configuration
|
211
|
+
# @param expectation_hash [Hash] The expectation configuration
|
212
|
+
#
|
213
|
+
# @return [String] A formatted expectation name (e.g., "GET /users - Find User")
|
214
|
+
#
|
215
|
+
# @private
|
216
|
+
#
|
217
|
+
def build_expectation_name(spec_hash, expectation_hash)
|
218
|
+
# Create a structure for these two attributes
|
219
|
+
# Removing the defaults and validators to avoid issues
|
220
|
+
structure = Normalizer::SHARED_ATTRIBUTES.slice(:http_verb, :url)
|
221
|
+
.transform_values { |v| v.except(:default, :validator) }
|
222
|
+
|
223
|
+
# Ignore any errors. It'll be caught above anyway
|
224
|
+
normalized_spec, _errors = Normalizer.new("", spec_hash, structure:).normalize
|
225
|
+
normalized_expectation, _errors = Normalizer.new("", expectation_hash, structure:).normalize
|
226
|
+
|
227
|
+
request_data = normalized_spec.deep_merge(normalized_expectation)
|
228
|
+
|
229
|
+
url = request_data[:url]
|
230
|
+
http_verb = request_data[:http_verb].presence || "GET"
|
231
|
+
|
232
|
+
# Finally generate the name
|
233
|
+
generate_expectation_name(http_verb:, url:, name: expectation_hash[:name])
|
234
|
+
end
|
235
|
+
|
236
|
+
#
|
237
|
+
# Generates an expectation name from its components
|
238
|
+
#
|
239
|
+
# @param http_verb [String] The HTTP verb (GET, POST, etc.)
|
240
|
+
# @param url [String] The URL path
|
241
|
+
# @param name [String, nil] Optional descriptive name
|
242
|
+
#
|
243
|
+
# @return [String] A formatted expectation name
|
244
|
+
#
|
245
|
+
# @private
|
246
|
+
#
|
247
|
+
def generate_expectation_name(http_verb:, url:, name: nil)
|
248
|
+
base = "#{http_verb.upcase} #{url}" # GET /users
|
249
|
+
base += " - #{name}" if name.present? # GET /users - Returns 404 because y not?
|
250
|
+
base
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
#
|
5
|
+
# Provides custom RSpec matchers for SpecForge
|
6
|
+
#
|
7
|
+
# This singleton class is responsible for defining custom RSpec matchers
|
8
|
+
# that can be used in SpecForge tests. It makes these matchers available
|
9
|
+
# through RSpec's matcher system.
|
10
|
+
#
|
11
|
+
# @example Defining all matchers
|
12
|
+
# SpecForge::Matchers.define
|
13
|
+
#
|
14
|
+
class Matchers
|
15
|
+
include Singleton
|
16
|
+
|
17
|
+
#
|
18
|
+
# Defines all custom matchers for use in SpecForge tests
|
19
|
+
#
|
20
|
+
# This is the main entry point that should be called once during
|
21
|
+
# initialization to make all custom matchers available.
|
22
|
+
#
|
23
|
+
def self.define
|
24
|
+
instance.define_all
|
25
|
+
end
|
26
|
+
|
27
|
+
#
|
28
|
+
# Defines all available custom matchers
|
29
|
+
#
|
30
|
+
# This method calls individual definition methods for each
|
31
|
+
# custom matcher supported by SpecForge.
|
32
|
+
#
|
33
|
+
def define_all
|
34
|
+
define_forge_and
|
35
|
+
define_have_size
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
#
|
41
|
+
# Defines the forge_and matcher for combining multiple matchers.
|
42
|
+
# Explicitly has "forge_" prefix to avoid potentially clashing with someone's
|
43
|
+
# existing custom matchers.
|
44
|
+
#
|
45
|
+
# This matcher allows chaining multiple matchers together with an AND
|
46
|
+
# condition, requiring all matchers to pass. It provides detailed
|
47
|
+
# failure messages showing which specific matchers failed.
|
48
|
+
#
|
49
|
+
# @example Using forge_and in a test
|
50
|
+
# expect(response.body).to forge_and(
|
51
|
+
# have_key("name"),
|
52
|
+
# have_key("email"),
|
53
|
+
# include("active" => be_truthy)
|
54
|
+
# )
|
55
|
+
#
|
56
|
+
# @private
|
57
|
+
#
|
58
|
+
def define_forge_and
|
59
|
+
RSpec::Matchers.define :forge_and do |*matchers|
|
60
|
+
match do |actual|
|
61
|
+
@failures = []
|
62
|
+
|
63
|
+
matchers.each do |matcher|
|
64
|
+
next if matcher.matches?(actual)
|
65
|
+
|
66
|
+
@failures << [matcher, matcher.failure_message]
|
67
|
+
end
|
68
|
+
|
69
|
+
@failures.empty?
|
70
|
+
end
|
71
|
+
|
72
|
+
failure_message do
|
73
|
+
pass_count = matchers.size - @failures.size
|
74
|
+
|
75
|
+
message = "Expected to satisfy ALL of these conditions on:\n #{actual.inspect}\n\n"
|
76
|
+
|
77
|
+
matchers.each_with_index do |matcher, i|
|
78
|
+
failure = @failures.find { |m, _| m == matcher }
|
79
|
+
|
80
|
+
if failure
|
81
|
+
message += "❌ #{i + 1}. #{matcher.description}\n"
|
82
|
+
message += " → #{failure[1].gsub(/\s+/, " ").strip}\n\n"
|
83
|
+
else
|
84
|
+
message += "✅ #{i + 1}. #{matcher.description}\n\n"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
message += "#{pass_count}/#{matchers.size} conditions met"
|
89
|
+
message
|
90
|
+
end
|
91
|
+
|
92
|
+
description do
|
93
|
+
"match all: " + matchers.join_map(", ", &:description)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
#
|
99
|
+
# Defines the have_size matcher for checking collection sizes
|
100
|
+
#
|
101
|
+
# This matcher verifies that an object responds to the :size method
|
102
|
+
# and that its size matches the expected value.
|
103
|
+
#
|
104
|
+
# @example Using have_size in a test
|
105
|
+
# expect(response.body["items"]).to have_size(5)
|
106
|
+
#
|
107
|
+
# @private
|
108
|
+
#
|
109
|
+
def define_have_size
|
110
|
+
RSpec::Matchers.define :have_size do |expected|
|
111
|
+
expected = RSpec::Matchers::BuiltIn::Eq.new(expected) if expected.is_a?(Integer)
|
112
|
+
|
113
|
+
match do |actual|
|
114
|
+
actual.respond_to?(:size) && expected.matches?(actual.size)
|
115
|
+
end
|
116
|
+
|
117
|
+
failure_message do |actual|
|
118
|
+
if actual.respond_to?(:size)
|
119
|
+
"expected #{actual.inspect} size to #{expected.description}, but got #{actual.size}"
|
120
|
+
else
|
121
|
+
"expected #{actual.inspect} to respond to :size"
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Define the custom matchers
|
130
|
+
SpecForge::Matchers.define
|
@@ -2,7 +2,23 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
class Normalizer
|
5
|
+
#
|
6
|
+
# Normalizes configuration hash structure for SpecForge
|
7
|
+
#
|
8
|
+
# Ensures that the global configuration has the correct structure
|
9
|
+
# and default values for all required settings.
|
10
|
+
#
|
5
11
|
class Configuration < Normalizer
|
12
|
+
#
|
13
|
+
# Defines the normalized structure for configuration validation
|
14
|
+
#
|
15
|
+
# Specifies validation rules for configuration attributes:
|
16
|
+
# - Enforces specific data types
|
17
|
+
# - Provides default values
|
18
|
+
# - Supports alternative key names
|
19
|
+
#
|
20
|
+
# @return [Hash] Configuration attribute validation rules
|
21
|
+
#
|
6
22
|
STRUCTURE = {
|
7
23
|
base_url: SHARED_ATTRIBUTES[:base_url].except(:default), # Make it required
|
8
24
|
headers: SHARED_ATTRIBUTES[:headers],
|
@@ -32,20 +48,20 @@ module SpecForge
|
|
32
48
|
#
|
33
49
|
# Generates an empty configuration hash
|
34
50
|
#
|
35
|
-
# @return [Hash]
|
51
|
+
# @return [Hash] Default configuration hash
|
36
52
|
#
|
37
53
|
def default_configuration
|
38
54
|
Configuration.default
|
39
55
|
end
|
40
56
|
|
41
57
|
#
|
42
|
-
# Normalizes a configuration hash
|
43
|
-
# is provided or defaulted.
|
44
|
-
# Raises InvalidStructureError if anything is missing/invalid type
|
58
|
+
# Normalizes a configuration hash with validation
|
45
59
|
#
|
46
60
|
# @param input [Hash] The hash to normalize
|
47
61
|
#
|
48
|
-
# @return [Hash] A normalized hash
|
62
|
+
# @return [Hash] A normalized hash with defaults applied
|
63
|
+
#
|
64
|
+
# @raise [Error::InvalidStructureError] If validation fails
|
49
65
|
#
|
50
66
|
def normalize_configuration!(input)
|
51
67
|
raise_errors! do
|
@@ -55,19 +71,16 @@ module SpecForge
|
|
55
71
|
|
56
72
|
#
|
57
73
|
# Normalize a configuration hash
|
58
|
-
# Used internally by .normalize_configuration!, but is available for utility
|
59
74
|
#
|
60
|
-
# @param configuration [Hash] Configuration
|
75
|
+
# @param configuration [Hash] Configuration hash
|
61
76
|
#
|
62
|
-
# @return [Array]
|
63
|
-
# First - The normalized hash
|
64
|
-
# Second - Array of errors, if any
|
77
|
+
# @return [Array] [normalized_hash, errors]
|
65
78
|
#
|
66
79
|
# @private
|
67
80
|
#
|
68
81
|
def normalize_configuration(configuration)
|
69
82
|
if !Type.hash?(configuration)
|
70
|
-
raise InvalidTypeError.new(configuration, Hash, for: "configuration")
|
83
|
+
raise Error::InvalidTypeError.new(configuration, Hash, for: "configuration")
|
71
84
|
end
|
72
85
|
|
73
86
|
Normalizer::Configuration.new("configuration", configuration).normalize
|