spec_forge 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.rspec +1 -0
  4. data/.standard.yml +3 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +426 -0
  9. data/Rakefile +6 -0
  10. data/bin/spec_forge +5 -0
  11. data/flake.lock +61 -0
  12. data/flake.nix +41 -0
  13. data/lib/spec_forge/attribute/chainable.rb +86 -0
  14. data/lib/spec_forge/attribute/factory.rb +63 -0
  15. data/lib/spec_forge/attribute/faker.rb +54 -0
  16. data/lib/spec_forge/attribute/literal.rb +27 -0
  17. data/lib/spec_forge/attribute/matcher.rb +118 -0
  18. data/lib/spec_forge/attribute/parameterized.rb +76 -0
  19. data/lib/spec_forge/attribute/resolvable.rb +21 -0
  20. data/lib/spec_forge/attribute/resolvable_array.rb +24 -0
  21. data/lib/spec_forge/attribute/resolvable_hash.rb +24 -0
  22. data/lib/spec_forge/attribute/transform.rb +39 -0
  23. data/lib/spec_forge/attribute/variable.rb +36 -0
  24. data/lib/spec_forge/attribute.rb +208 -0
  25. data/lib/spec_forge/cli/actions.rb +23 -0
  26. data/lib/spec_forge/cli/command.rb +127 -0
  27. data/lib/spec_forge/cli/init.rb +29 -0
  28. data/lib/spec_forge/cli/new.rb +161 -0
  29. data/lib/spec_forge/cli/run.rb +17 -0
  30. data/lib/spec_forge/cli.rb +43 -0
  31. data/lib/spec_forge/config.rb +84 -0
  32. data/lib/spec_forge/environment.rb +71 -0
  33. data/lib/spec_forge/error.rb +150 -0
  34. data/lib/spec_forge/factory.rb +104 -0
  35. data/lib/spec_forge/http/backend.rb +106 -0
  36. data/lib/spec_forge/http/client.rb +33 -0
  37. data/lib/spec_forge/http/request.rb +93 -0
  38. data/lib/spec_forge/http/verb.rb +118 -0
  39. data/lib/spec_forge/http.rb +6 -0
  40. data/lib/spec_forge/normalizer/config.rb +104 -0
  41. data/lib/spec_forge/normalizer/constraint.rb +47 -0
  42. data/lib/spec_forge/normalizer/expectation.rb +85 -0
  43. data/lib/spec_forge/normalizer/factory.rb +65 -0
  44. data/lib/spec_forge/normalizer/factory_reference.rb +66 -0
  45. data/lib/spec_forge/normalizer/spec.rb +73 -0
  46. data/lib/spec_forge/normalizer.rb +183 -0
  47. data/lib/spec_forge/runner.rb +91 -0
  48. data/lib/spec_forge/spec/expectation/constraint.rb +52 -0
  49. data/lib/spec_forge/spec/expectation.rb +53 -0
  50. data/lib/spec_forge/spec.rb +77 -0
  51. data/lib/spec_forge/type.rb +45 -0
  52. data/lib/spec_forge/version.rb +5 -0
  53. data/lib/spec_forge.rb +90 -0
  54. data/lib/templates/config.tt +19 -0
  55. data/spec_forge/config.yml +19 -0
  56. data/spec_forge/factories/user.yml +4 -0
  57. data/spec_forge/specs/users.yml +63 -0
  58. metadata +234 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Environment
5
+ attr_reader :environment, :framework
6
+
7
+ #
8
+ # Creates a new environment loader
9
+ #
10
+ # @param environment [Config::Environment] The environment to load
11
+ #
12
+ def initialize(environment = SpecForge.config.environment)
13
+ @environment = environment
14
+ @framework = environment.use
15
+ end
16
+
17
+ #
18
+ # Loads the environment
19
+ #
20
+ def load
21
+ load_framework
22
+ load_preload
23
+
24
+ self
25
+ end
26
+
27
+ private
28
+
29
+ def load_framework
30
+ case framework
31
+ when "rails"
32
+ load_rails
33
+ else
34
+ load_generic
35
+ end
36
+ end
37
+
38
+ def load_rails
39
+ path = SpecForge.root.join("config", "environment.rb")
40
+
41
+ if File.exist?(path)
42
+ require path
43
+ else
44
+ warn <<~WARNING.chomp
45
+ SpecForge warning: Config attribute "environment" set to "rails" but Rails environment (config/environment.rb) does not exist.
46
+ Factories or model-dependent features may not function as expected.
47
+ - For non-Rails projects, set your environment's 'models_path' or 'preload' in your config.yml
48
+ - To disable this warning, set 'environment: ""' in your config.yml.
49
+ WARNING
50
+ end
51
+ end
52
+
53
+ def load_generic
54
+ return unless environment.models_path? && environment.models_path.present?
55
+
56
+ models_path = SpecForge.root.join(environment.models_path)
57
+ return if !File.exist?(models_path)
58
+
59
+ Dir[models_path.join("**/*.rb")].each { |file| require file }
60
+ end
61
+
62
+ def load_preload
63
+ return unless environment.preload? && environment.preload.present?
64
+
65
+ path = SpecForge.root.join(environment.preload)
66
+ return if !File.exist?(path)
67
+
68
+ require path
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ # Pass into to_sentence
5
+ OR_CONNECTOR = {
6
+ last_word_connector: ", or ",
7
+ two_words_connector: " or ",
8
+ # This is a minor performance improvement to avoid locales being loaded
9
+ # This will need to be removed if locales are added
10
+ locale: false
11
+ }.freeze
12
+
13
+ private_constant :OR_CONNECTOR
14
+
15
+ class Error < StandardError; end
16
+
17
+ #
18
+ # Raised by Attribute::Faker when a provided classname does not exist in Faker
19
+ #
20
+ class InvalidFakerClassError < Error
21
+ CLASS_CHECKER = DidYouMean::SpellChecker.new(
22
+ dictionary: Faker::Base.descendants.map { |c| c.to_s.downcase.gsub!("::", ".") }
23
+ )
24
+
25
+ def initialize(input)
26
+ corrections = CLASS_CHECKER.correct(input)
27
+
28
+ super(<<~STRING.chomp
29
+ Undefined Faker class "#{input}". #{DidYouMean::Formatter.message_for(corrections)}
30
+
31
+ For available classes, please check https://github.com/faker-ruby/faker#generators.
32
+ STRING
33
+ )
34
+ end
35
+ end
36
+
37
+ #
38
+ # Raised by Attribute::Faker when a provided method for a Faker class does not exist.
39
+ #
40
+ class InvalidFakerMethodError < Error
41
+ def initialize(input, klass)
42
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: klass.public_methods)
43
+ corrections = spell_checker.correct(input)
44
+
45
+ super(<<~STRING.chomp
46
+ Undefined Faker method "#{input}" for "#{klass}". #{DidYouMean::Formatter.message_for(corrections)}
47
+
48
+ For available methods for this class, please check https://github.com/faker-ruby/faker#generators.
49
+ STRING
50
+ )
51
+ end
52
+ end
53
+
54
+ #
55
+ # Raised by Attribute::Transform when the provided transform function is not valid
56
+ #
57
+ class InvalidTransformFunctionError < Error
58
+ def initialize(input)
59
+ # TODO: Update link to docs
60
+ super(<<~STRING.chomp
61
+ Undefined transform function "#{input}".
62
+
63
+ For available functions, please check https://github.com/itsthedevman/spec_forge.
64
+ STRING
65
+ )
66
+ end
67
+ end
68
+
69
+ #
70
+ # Raised by Attribute::Chainable when an step in the invocation chain is invalid
71
+ #
72
+ class InvalidInvocationError < Error
73
+ def initialize(step, object)
74
+ valid_operations =
75
+ case object
76
+ when ArrayLike
77
+ "Array index (0, 1, 2, etc.) or any Array methods (first, last, size, etc.)"
78
+ when HashLike
79
+ "Any Hash key: #{object.keys.join(", ")}"
80
+ else
81
+ "Any method available on #{object.class}"
82
+ end
83
+
84
+ super(<<~STRING.chomp
85
+ Cannot invoke "#{step}" on #{object.class}.
86
+
87
+ Valid operations include: #{valid_operations}
88
+ STRING
89
+ )
90
+ end
91
+ end
92
+
93
+ #
94
+ # An extended version of TypeError to make things easier when reporting invalid types
95
+ #
96
+ class InvalidTypeError < Error
97
+ def initialize(object, expected_type, **opts)
98
+ if expected_type.instance_of?(Array)
99
+ expected_type = expected_type.to_sentence(**OR_CONNECTOR)
100
+ end
101
+
102
+ message = "Expected #{expected_type}, got #{object.class}"
103
+ message += " for #{opts[:for]}" if opts[:for].present?
104
+
105
+ super(message)
106
+ end
107
+ end
108
+
109
+ #
110
+ # Raised by Attribute::Variable when the provided variable name is not defined
111
+ #
112
+ class MissingVariableError < Error
113
+ def initialize(variable_name)
114
+ super("Undefined variable \"#{variable_name}\" referenced in expectation")
115
+ end
116
+ end
117
+
118
+ #
119
+ # Raised by Normalizer when any errors are returned. Acts like a grouping of errors
120
+ #
121
+ class InvalidStructureError < Error
122
+ def initialize(errors)
123
+ message = errors.to_a.join_map("\n") do |error|
124
+ next error if error.is_a?(SpecForge::Error)
125
+
126
+ # Normal errors, let's get verbose
127
+ backtrace = SpecForge.backtrace_cleaner.clean(error.backtrace)
128
+ "#{error.inspect}\n # ./#{backtrace.join("\n # ./")}\n"
129
+ end
130
+
131
+ super(message)
132
+ end
133
+ end
134
+
135
+ #
136
+ # Raised by Attribute::Factory when an unknown build strategy is provided
137
+ #
138
+ class InvalidBuildStrategy < Error
139
+ def initialize(build_strategy)
140
+ valid_strategies = Attribute::Factory::BUILD_STRATEGIES.to_sentence(**OR_CONNECTOR)
141
+
142
+ super(<<~STRING.chomp
143
+ Unknown build strategy "#{build_strategy}" referenced in spec.
144
+
145
+ Valid strategies include: #{valid_strategies}
146
+ STRING
147
+ )
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Factory
5
+ #
6
+ # Loads the factories from their yml files and registers them with FactoryBot
7
+ #
8
+ # @param path [String, Path] The base path where the factories directory are located
9
+ #
10
+ def self.load_and_register(base_path)
11
+ if (paths = SpecForge.config.factories.paths) && paths.size > 0
12
+ FactoryBot.definition_file_paths = paths
13
+ end
14
+
15
+ FactoryBot.find_definitions if SpecForge.config.factories.auto_discover?
16
+
17
+ factories = load_from_path(base_path.join("factories", "**/*.yml"))
18
+ factories.each(&:register)
19
+ end
20
+
21
+ #
22
+ # Loads any factories defined in the path. A single file can contain one or more factories
23
+ #
24
+ # @param path [String, Path] The path where the factories are located
25
+ #
26
+ # @return [Array<Factory>] An array of factories that were loaded.
27
+ # Note: This factories have not been registered with FactoryBot.
28
+ # See #register
29
+ #
30
+ def self.load_from_path(path)
31
+ factories = []
32
+
33
+ Dir[path].map do |file_path|
34
+ hash = YAML.load_file(file_path).deep_symbolize_keys
35
+
36
+ hash.each do |factory_name, factory_hash|
37
+ factory_hash[:name] = factory_name
38
+
39
+ factories << new(**factory_hash)
40
+ end
41
+ end
42
+
43
+ factories
44
+ end
45
+
46
+ ############################################################################
47
+
48
+ attr_reader :name, :input, :model_class, :variables, :attributes
49
+
50
+ #
51
+ # Creates a new Factory
52
+ #
53
+ # @param name [String] The name of the factory
54
+ # @param **input [Hash] Attributes to define the factory. See Normalizer::Factory
55
+ #
56
+ def initialize(name:, **input)
57
+ @name = name
58
+ input = Normalizer.normalize_factory!(input)
59
+
60
+ @input = input
61
+ @model_class = input[:model_class]
62
+
63
+ @variables = extract_variables(input)
64
+ @attributes = extract_attributes(input)
65
+ end
66
+
67
+ #
68
+ # Registers this factory with FactoryBot.
69
+ # Once registered, you can call FactoryBot.build and other methods
70
+ #
71
+ # @return [Self]
72
+ #
73
+ def register
74
+ dsl = FactoryBot::Syntax::Default::DSL.new
75
+
76
+ options = {}
77
+ options[:class] = model_class if model_class
78
+
79
+ # This creates the factory in FactoryBot
80
+ factory_forge = self
81
+ dsl.factory(name, options) do
82
+ factory_forge.attributes.each do |name, attribute|
83
+ add_attribute(name, &attribute.to_proc)
84
+ end
85
+ end
86
+
87
+ self
88
+ end
89
+
90
+ private
91
+
92
+ def extract_variables(input)
93
+ variables = Attribute.from(input[:variables])
94
+
95
+ # Update the variables that reference other variables lol
96
+ Attribute.bind_variables(variables, variables)
97
+ end
98
+
99
+ def extract_attributes(input)
100
+ attributes = Attribute.from(input[:attributes])
101
+ Attribute.bind_variables(attributes, variables)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ module HTTP
5
+ class Backend
6
+ attr_reader :connection
7
+
8
+ #
9
+ # Configures Faraday with the config values
10
+ #
11
+ # @param request [HTTP::Request]
12
+ #
13
+ def initialize(request)
14
+ @connection =
15
+ Faraday.new(url: request.base_url) do |builder|
16
+ # Authorization
17
+ builder.headers[request.authorization.header] = request.authorization.value
18
+
19
+ # Content-Type
20
+ if !request.headers.key?("Content-Type")
21
+ builder.request :json
22
+ builder.response :json
23
+ end
24
+
25
+ # Headers / Content Type
26
+ builder.headers.merge!(request.headers)
27
+
28
+ # Params
29
+ builder.params.merge!(request.query.resolve)
30
+ end
31
+ end
32
+
33
+ #
34
+ # Executes a DELETE request to <base_url>/<provided_url>
35
+ #
36
+ # @param url [String] The URL path to DELETE
37
+ # @param query [Hash] Any query attributes to send
38
+ # @param body [Hash] Any body data to send
39
+ #
40
+ # @return [Hash] The response
41
+ #
42
+ def delete(url, query: {}, body: {})
43
+ connection.delete(url) { |request| update_request(request, query, body) }
44
+ end
45
+
46
+ #
47
+ # Executes a GET request to <base_url>/<provided_url>
48
+ #
49
+ # @param url [String] The URL path to GET
50
+ # @param query [Hash] Any query attributes to send
51
+ # @param body [Hash] Any body data to send
52
+ #
53
+ # @return [Hash] The response
54
+ #
55
+ def get(url, query: {}, body: {})
56
+ connection.get(url) { |request| update_request(request, query, body) }
57
+ end
58
+
59
+ #
60
+ # Executes a PATCH request to <base_url>/<provided_url>
61
+ #
62
+ # @param url [String] The URL path to PATCH
63
+ # @param query [Hash] Any query attributes to send
64
+ # @param body [Hash] Any body data to send
65
+ #
66
+ # @return [Hash] The response
67
+ #
68
+ def patch(url, query: {}, body: {})
69
+ connection.patch(url) { |request| update_request(request, query, body) }
70
+ end
71
+
72
+ #
73
+ # Executes a POST request to <base_url>/<provided_url>
74
+ #
75
+ # @param url [String] The URL path to POST
76
+ # @param query [Hash] Any query attributes to send
77
+ # @param body [Hash] Any body data to send
78
+ #
79
+ # @return [Hash] The response
80
+ #
81
+ def post(url, query: {}, body: {})
82
+ connection.post(url) { |request| update_request(request, query, body) }
83
+ end
84
+
85
+ #
86
+ # Executes a PUT request to <base_url>/<provided_url>
87
+ #
88
+ # @param url [String] The URL path to PUT
89
+ # @param query [Hash] Any query attributes to send
90
+ # @param body [Hash] Any body data to send
91
+ #
92
+ # @return [Hash] The response
93
+ #
94
+ def put(url, query: {}, body: {})
95
+ connection.put(url) { |request| update_request(request, query, body) }
96
+ end
97
+
98
+ private
99
+
100
+ def update_request(request, query, body)
101
+ request.params.merge!(query)
102
+ request.body = body.to_json
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ module HTTP
5
+ class Client
6
+ attr_reader :request
7
+
8
+ #
9
+ # Creates a new HTTP client to middleman between the tests and the backend
10
+ #
11
+ # @param ** [Hash] Request attributes
12
+ #
13
+ def initialize(**)
14
+ @request = Request.new(**)
15
+ @adapter = Backend.new(request)
16
+ end
17
+
18
+ #
19
+ # Triggers an HTTP request to the URL
20
+ #
21
+ # @return [Hash] The response
22
+ #
23
+ def call
24
+ @adapter.public_send(
25
+ request.http_verb,
26
+ request.url,
27
+ query: request.query.transform_values(&:resolve),
28
+ body: request.body.transform_values(&:resolve)
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ module HTTP
5
+ attributes = [:base_url, :url, :http_method, :headers, :query, :body, :authorization]
6
+
7
+ class Request < Data.define(*attributes)
8
+ HEADER = /^[A-Z][A-Za-z0-9!-]*$/
9
+
10
+ #
11
+ # Initializes a new Request instance with the given options
12
+ #
13
+ # @param [Hash] options The options to create the Request with
14
+ #
15
+ # @option options [String] :url The request URL
16
+ #
17
+ # @option options [String, Verb] :http_method The HTTP method to use
18
+ #
19
+ # @option options [Hash] :headers Any headers
20
+ #
21
+ # @option options [Hash] :query The query parameters for the request (defaults to {})
22
+ #
23
+ # @option options [Hash] :body The request body (defaults to {})
24
+ #
25
+ def initialize(**options)
26
+ base_url = extract_base_url(options)
27
+ url = extract_url(options)
28
+ headers = normalize_headers(options)
29
+ http_method = normalize_http_method(options)
30
+ query = normalize_query(options)
31
+ body = normalize_body(options)
32
+ authorization = extract_authorization(options)
33
+
34
+ super(base_url:, url:, http_method:, headers:, query:, body:, authorization:)
35
+ end
36
+
37
+ def http_verb
38
+ http_method.name.downcase
39
+ end
40
+
41
+ private
42
+
43
+ def extract_base_url(options)
44
+ options[:base_url]&.value&.presence || SpecForge.config.base_url
45
+ end
46
+
47
+ def extract_url(options)
48
+ options[:url].value
49
+ end
50
+
51
+ def normalize_http_method(options)
52
+ method = options[:http_method].value.presence || "GET"
53
+
54
+ if method.is_a?(String)
55
+ Verb.from(method)
56
+ else
57
+ method
58
+ end
59
+ end
60
+
61
+ def normalize_headers(options)
62
+ headers = options[:headers].transform_keys do |key|
63
+ key = key.to_s
64
+
65
+ # If the key is already like a header, don't change it
66
+ if key.match?(HEADER)
67
+ key
68
+ else
69
+ # content_type => Content-Type
70
+ key.downcase.titleize.gsub(/\s+/, "-")
71
+ end
72
+ end
73
+
74
+ headers = Attribute.bind_variables(headers, options[:variables])
75
+ Attribute::ResolvableHash.new(headers)
76
+ end
77
+
78
+ def normalize_query(options)
79
+ query = Attribute.bind_variables(options[:query], options[:variables])
80
+ Attribute::ResolvableHash.new(query)
81
+ end
82
+
83
+ def normalize_body(options)
84
+ body = Attribute.bind_variables(options[:body], options[:variables])
85
+ Attribute::ResolvableHash.new(body)
86
+ end
87
+
88
+ def extract_authorization(options)
89
+ SpecForge.config.authorization.default
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ module HTTP
5
+ class Verb < Data.define(:name)
6
+ class Delete < Verb
7
+ def initialize = super(name: "DELETE")
8
+ end
9
+
10
+ class Get < Verb
11
+ def initialize = super(name: "GET")
12
+ end
13
+
14
+ class Patch < Verb
15
+ def initialize = super(name: "PATCH")
16
+ end
17
+
18
+ class Post < Verb
19
+ def initialize = super(name: "POST")
20
+ end
21
+
22
+ class Put < Verb
23
+ def initialize = super(name: "PUT")
24
+ end
25
+
26
+ DELETE = Delete.new
27
+ GET = Get.new
28
+ PATCH = Patch.new
29
+ POST = Post.new
30
+ PUT = Put.new
31
+
32
+ VERBS = {
33
+ delete: DELETE,
34
+ get: GET,
35
+ patch: PATCH,
36
+ post: POST,
37
+ put: PUT
38
+ }.freeze
39
+
40
+ #
41
+ # Retrieves the corresponding Verb instance based on the provided HTTP name
42
+ #
43
+ # @param name [String, Symbol] The HTTP name to look up (case-insensitive)
44
+ #
45
+ # @return [Verb, nil] The corresponding Verb instance, or nil if not found
46
+ #
47
+ def self.from(name)
48
+ VERBS[name.downcase.to_sym]
49
+ end
50
+
51
+ #
52
+ # Returns if this Verb name matches another Verb's name, or the name
53
+ # as a String or Symbol
54
+ #
55
+ # @param other [Object] The thing to check against this object
56
+ #
57
+ # @return [Boolean]
58
+ #
59
+ def ==(other)
60
+ case other
61
+ when Verb
62
+ name == other.name
63
+ when String, Symbol
64
+ self == self.class.from(other)
65
+ else
66
+ false
67
+ end
68
+ end
69
+
70
+ alias_method :to_s, :name
71
+
72
+ #
73
+ # Returns if this Verb is a DELETE
74
+ #
75
+ # @return [Boolean]
76
+ #
77
+ def delete?
78
+ name == "DELETE"
79
+ end
80
+
81
+ #
82
+ # Returns if this Verb is a GET
83
+ #
84
+ # @return [Boolean]
85
+ #
86
+ def get?
87
+ name == "GET"
88
+ end
89
+
90
+ #
91
+ # Returns if this Verb is a PATCH
92
+ #
93
+ # @return [Boolean]
94
+ #
95
+ def patch?
96
+ name == "PATCH"
97
+ end
98
+
99
+ #
100
+ # Returns if this Verb is a POST
101
+ #
102
+ # @return [Boolean]
103
+ #
104
+ def post?
105
+ name == "POST"
106
+ end
107
+
108
+ #
109
+ # Returns if this Verb is a PUT
110
+ #
111
+ # @return [Boolean]
112
+ #
113
+ def put?
114
+ name == "PUT"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http/backend"
4
+ require_relative "http/client"
5
+ require_relative "http/verb"
6
+ require_relative "http/request"