teckel 0.4.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ RSpec.describe Teckel::Operation do
6
+ let(:operation) do
7
+ Class.new do
8
+ include Teckel::Operation
9
+ input none
10
+ output ->(o) { o }
11
+ error none
12
+
13
+ def call(_)
14
+ success! settings
15
+ end
16
+ end
17
+ end
18
+
19
+ let(:blank_operation) do
20
+ Class.new do
21
+ include Teckel::Operation
22
+ end
23
+ end
24
+
25
+ describe ".settings" do
26
+ specify "no settings" do
27
+ expect(operation.settings).to eq(Teckel::Contracts::None)
28
+ expect(operation.settings_constructor).to eq(Teckel::Contracts::None.method(:new))
29
+ end
30
+
31
+ specify "with settings klass" do
32
+ settings_klass = Struct.new(:name)
33
+ operation.settings(settings_klass)
34
+ expect(operation.settings).to eq(settings_klass)
35
+ end
36
+
37
+ specify "without settings class, with settings constructor as proc" do
38
+ settings_const = if RUBY_VERSION < '2.6.0'
39
+ ->(sets) { sets.map { |k, v| [k.to_s, v.to_i] }.to_h }
40
+ else
41
+ ->(sets) { sets.to_h { |k, v| [k.to_s, v.to_i] } }
42
+ end
43
+
44
+ operation.settings_constructor(settings_const)
45
+
46
+ expect(operation.settings).to eq(Teckel::Contracts::None)
47
+ expect(operation.settings_constructor).to eq(settings_const)
48
+
49
+ runner = operation.with(key: "1")
50
+ expect(runner).to be_a(Teckel::Operation::Runner)
51
+ expect(runner.settings).to eq({ "key" => 1 })
52
+ end
53
+
54
+ specify "with settings class, with settings constructor as symbol" do
55
+ settings_klass = Struct.new(:name) do
56
+ def self.make_one(opts)
57
+ new(opts[:name])
58
+ end
59
+ end
60
+
61
+ operation.settings(settings_klass)
62
+ operation.settings_constructor(:make_one)
63
+
64
+ expect(operation.settings).to eq(settings_klass)
65
+ expect(operation.settings_constructor).to eq(settings_klass.method(:make_one))
66
+
67
+ runner = operation.with(name: "value")
68
+ expect(runner).to be_a(Teckel::Operation::Runner)
69
+ expect(runner.settings).to be_a(settings_klass)
70
+ expect(runner.settings.name).to eq("value")
71
+ end
72
+
73
+ specify "with settings class as constant" do
74
+ settings_klass = Struct.new(:name)
75
+ operation.const_set(:Settings, settings_klass)
76
+
77
+ expect(operation.settings).to eq(settings_klass)
78
+ expect(operation.settings_constructor).to eq(settings_klass.method(:[]))
79
+ end
80
+ end
81
+
82
+ describe ".default_settings" do
83
+ specify "no default_settings" do
84
+ expect(operation.default_settings).to be_nil
85
+ expect(operation.runner).to receive(:new).with(operation).and_call_original
86
+
87
+ operation.call
88
+ end
89
+
90
+ specify "default_settings!() with no default_settings" do
91
+ operation.default_settings!
92
+ expect(operation.default_settings).to be_a(Proc)
93
+
94
+ expect(operation.default_settings).to receive(:call).with(no_args).and_wrap_original do |original_method, *args, &block|
95
+ settings = original_method.call(*args, &block)
96
+ expect(settings).to be_nil
97
+ expect(operation.runner).to receive(:new).with(operation, settings).and_call_original
98
+ settings
99
+ end
100
+
101
+ operation.call
102
+ end
103
+
104
+ specify "default_settings!() with default_settings" do
105
+ settings_klass = Struct.new(:name)
106
+
107
+ operation.settings(settings_klass)
108
+ operation.default_settings!
109
+
110
+ expect(operation.default_settings).to be_a(Proc)
111
+
112
+ expect(operation.default_settings).to receive(:call).with(no_args).and_wrap_original do |original_method, *args, &block|
113
+ settings = original_method.call(*args, &block)
114
+ expect(settings).to be_a(settings_klass).and have_attributes(name: nil)
115
+ expect(operation.runner).to receive(:new).with(operation, settings).and_call_original
116
+ settings
117
+ end
118
+
119
+ operation.call
120
+ end
121
+
122
+ specify "default_settings!(arg) with default_settings" do
123
+ settings_klass = Struct.new(:name)
124
+
125
+ operation.settings(settings_klass)
126
+ operation.default_settings!("Bob")
127
+
128
+ expect(operation.default_settings).to be_a(Proc)
129
+
130
+ expect(operation.default_settings).to receive(:call).with(no_args).and_wrap_original do |original_method, *args, &block|
131
+ settings = original_method.call(*args, &block)
132
+ expect(settings).to be_a(settings_klass).and have_attributes(name: "Bob")
133
+ expect(operation.runner).to receive(:new).with(operation, settings).and_call_original
134
+ settings
135
+ end
136
+
137
+ expect(operation.call).to be_a(Struct).and have_attributes(name: "Bob")
138
+ end
139
+ end
140
+
141
+ %i[input output error].each do |meth|
142
+ describe ".#{meth}" do
143
+ specify "missing .#{meth} config raises MissingConfigError" do
144
+ expect {
145
+ blank_operation.public_send(meth)
146
+ }.to raise_error(Teckel::MissingConfigError, "Missing #{meth} config for #{blank_operation}")
147
+ end
148
+ end
149
+
150
+ describe ".#{meth}_constructor" do
151
+ specify "missing .#{meth}_constructor config raises MissingConfigError for missing #{meth}" do
152
+ expect {
153
+ blank_operation.public_send(:"#{meth}_constructor")
154
+ }.to raise_error(Teckel::MissingConfigError, "Missing #{meth} config for #{blank_operation}")
155
+ end
156
+ end
157
+ end
158
+
159
+ specify "default settings config" do
160
+ expect(blank_operation.settings).to eq(Teckel::Contracts::None)
161
+ end
162
+
163
+ specify "default settings_constructor" do
164
+ expect(blank_operation.settings_constructor).to eq(Teckel::Contracts::None.method(:[]))
165
+ end
166
+
167
+ specify "default settings_constructor with settings config set" do
168
+ settings_klass = Struct.new(:name)
169
+ blank_operation.settings(settings_klass)
170
+
171
+ expect(blank_operation.settings_constructor).to eq(settings_klass.method(:[]))
172
+ end
173
+
174
+ specify "unsupported constructor method" do
175
+ blank_operation.settings(Class.new)
176
+ expect {
177
+ blank_operation.settings_constructor(:nope)
178
+ }.to raise_error(Teckel::MissingConfigError, "Missing settings_constructor config for #{blank_operation}")
179
+
180
+ expect {
181
+ blank_operation.settings_constructor
182
+ }.to raise_error(Teckel::MissingConfigError, "Missing settings_constructor config for #{blank_operation}")
183
+ end
184
+
185
+ describe "result" do
186
+ specify "default result config" do
187
+ expect(blank_operation.result).to eq(Teckel::Operation::ValueResult)
188
+ end
189
+
190
+ specify "default result_constructor" do
191
+ expect(blank_operation.result_constructor).to eq(Teckel::Operation::ValueResult.method(:[]))
192
+ end
193
+
194
+ specify "default result_constructor with settings config set" do
195
+ result_klass = OpenStruct.new
196
+ blank_operation.result(result_klass)
197
+
198
+ expect(blank_operation.result_constructor).to eq(result_klass.method(:[]))
199
+ end
200
+
201
+ specify "unsupported constructor method" do
202
+ blank_operation.result(Class.new)
203
+ expect {
204
+ blank_operation.result_constructor(:nope)
205
+ }.to raise_error(Teckel::MissingConfigError, "Missing result_constructor config for #{blank_operation}")
206
+
207
+ expect {
208
+ blank_operation.result_constructor
209
+ }.to raise_error(Teckel::MissingConfigError, "Missing result_constructor config for #{blank_operation}")
210
+ end
211
+
212
+ specify "with result class as constant" do
213
+ result_klass = OpenStruct.new
214
+ blank_operation.const_set(:Result, result_klass)
215
+
216
+ expect(blank_operation.result).to eq(result_klass)
217
+ expect(blank_operation.result_constructor).to eq(result_klass.method(:[]))
218
+ end
219
+ end
220
+
221
+ describe "result!" do
222
+ specify "default result config" do
223
+ blank_operation.result!
224
+ expect(blank_operation.result).to eq(Teckel::Operation::Result)
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'support/dry_base'
4
+
5
+ module TeckelOperationContractTrace
6
+ DefaultError = Struct.new(:message, :status_code)
7
+ Settings = Struct.new(:fail_it)
8
+
9
+ class ApplicationOperation
10
+ include Teckel::Operation
11
+
12
+ class Input < Dry::Struct
13
+ attribute :input_data, Types::String
14
+ end
15
+
16
+ class Output < Dry::Struct
17
+ attribute :output_data, Types::String
18
+ end
19
+
20
+ class Error < Dry::Struct
21
+ attribute :error_data, Types::String
22
+ end
23
+
24
+ # Freeze the base class to make sure it's inheritable configuration is not altered
25
+ freeze
26
+ end
27
+ end
28
+
29
+ # rubocop:disable Style/EvalWithLocation
30
+ # Hack to get reliable stack traces
31
+ eval <<~RUBY, binding, "operation_success_error.rb"
32
+ module TeckelOperationContractTrace
33
+ class OperationSuccessError < ApplicationOperation
34
+ # Includes a deliberate bug while crating a success output
35
+ def call(input)
36
+ success!(incorrect_key: 1)
37
+ end
38
+ end
39
+ end
40
+ RUBY
41
+
42
+ eval <<~RUBY, binding, "operation_simple_success_error.rb"
43
+ module TeckelOperationContractTrace
44
+ class OperationSimpleSuccessNil < ApplicationOperation
45
+ # Includes a deliberate bug while crating a success output
46
+ def call(input)
47
+ return { incorrect_key: 1 }
48
+ end
49
+ end
50
+ end
51
+ RUBY
52
+
53
+ eval <<~RUBY, binding, "operation_failure_error.rb"
54
+ module TeckelOperationContractTrace
55
+ class OperationFailureError < ApplicationOperation
56
+ # Includes a deliberate bug while crating an error output
57
+ def call(input)
58
+ fail!(incorrect_key: 1)
59
+ end
60
+ end
61
+ end
62
+ RUBY
63
+
64
+ eval <<~RUBY, binding, "operation_ok.rb"
65
+ module TeckelOperationContractTrace
66
+ class OperationOk < ApplicationOperation
67
+ def call(input)
68
+ success!(output_data: "all fine")
69
+ end
70
+ end
71
+ end
72
+ RUBY
73
+
74
+ eval <<~RUBY, binding, "operation_input_error.rb"
75
+ module TeckelOperationContractTrace
76
+ def self.run_operation(operation)
77
+ operation.call(error_input_data: "failure")
78
+ end
79
+ end
80
+ RUBY
81
+ # rubocop:enable Style/EvalWithLocation
82
+
83
+ RSpec.describe Teckel::Operation do
84
+ context "contract errors include meaningful trace" do
85
+ specify "incorrect success" do
86
+ expect {
87
+ TeckelOperationContractTrace::OperationSuccessError.call(input_data: "ok")
88
+ }.to raise_error(Dry::Struct::Error) { |error|
89
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_success_error.rb:5:in `call'")}$/
90
+ }
91
+ end
92
+
93
+ specify "incorrect success via simple return results in +nil+, but no meaningful trace" do
94
+ expect(
95
+ TeckelOperationContractTrace::OperationSimpleSuccessNil.call(input_data: "ok")
96
+ ).to be_nil
97
+ end
98
+
99
+ specify "incorrect fail" do
100
+ expect {
101
+ TeckelOperationContractTrace::OperationFailureError.call(input_data: "ok")
102
+ }.to raise_error(Dry::Struct::Error) { |error|
103
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_failure_error.rb:5:in `call'")}$/
104
+ }
105
+ end
106
+
107
+ specify "incorrect input" do
108
+ operation = TeckelOperationContractTrace::OperationOk
109
+
110
+ expect(operation.call(input_data: "ok")).to eq(operation.output[output_data: "all fine"])
111
+ expect {
112
+ TeckelOperationContractTrace.run_operation(operation)
113
+ }.to raise_error(Dry::Struct::Error) { |error|
114
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_input_error.rb:3:in `run_operation'")}$/
115
+ }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TeckelOperationDefaultSettings
4
+ class BaseOperation
5
+ include ::Teckel::Operation
6
+
7
+ input none
8
+ output Symbol
9
+ error none
10
+
11
+ def call(_input)
12
+ success! settings.injected
13
+ end
14
+ end
15
+ end
16
+
17
+ RSpec.describe Teckel::Operation do
18
+ context "default settings" do
19
+ shared_examples "operation with default settings" do |operation|
20
+ subject { operation }
21
+
22
+ it "with no settings" do
23
+ expect(subject.call).to eq(:default_value)
24
+ end
25
+
26
+ it "with settings" do
27
+ expect(subject.with(:injected_value).call).to eq(:injected_value)
28
+ end
29
+ end
30
+
31
+ describe "with default constructor and clever Settings class" do
32
+ it_behaves_like(
33
+ "operation with default settings",
34
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
35
+ settings(Class.new do
36
+ def initialize(injected = nil)
37
+ @injected = injected
38
+ end
39
+
40
+ def injected
41
+ @injected || :default_value
42
+ end
43
+
44
+ class << self
45
+ alias :[] :new # make us respond to the default constructor
46
+ end
47
+ end)
48
+
49
+ default_settings!
50
+ end
51
+ )
52
+ end
53
+
54
+ describe "with custom constructor and clever Settings class" do
55
+ it_behaves_like(
56
+ "operation with default settings",
57
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
58
+ settings(Class.new do
59
+ def initialize(injected = nil)
60
+ @injected = injected
61
+ end
62
+
63
+ def injected
64
+ @injected || :default_value
65
+ end
66
+ end)
67
+
68
+ settings_constructor :new
69
+ default_settings!
70
+ end
71
+ )
72
+ end
73
+
74
+ describe "with default constructor and simple Settings class" do
75
+ it_behaves_like(
76
+ "operation with default settings",
77
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
78
+ settings Struct.new(:injected)
79
+
80
+ default_settings! -> { settings.new(:default_value) }
81
+ end
82
+ )
83
+
84
+ it_behaves_like(
85
+ "operation with default settings",
86
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
87
+ settings Struct.new(:injected)
88
+
89
+ default_settings!(:default_value)
90
+ end
91
+ )
92
+
93
+ it_behaves_like(
94
+ "operation with default settings",
95
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
96
+ settings Struct.new(:injected)
97
+
98
+ default_settings!("default_value")
99
+
100
+ output_constructor ->(out) { out&.to_sym }
101
+ end
102
+ )
103
+ end
104
+
105
+ describe "with default constructor and simple Settings class responding to passed default setting" do
106
+ it_behaves_like(
107
+ "operation with default settings",
108
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
109
+ settings(Struct.new(:injected) do
110
+ def self.default
111
+ new(:default_value)
112
+ end
113
+ end)
114
+
115
+ default_settings!(:default)
116
+ end
117
+ )
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+ require 'support/dry_base'
5
+ require 'support/fake_models'
6
+
7
+ module TeckelOperationFailOnOInput
8
+ class NewUserContract < Dry::Validation::Contract
9
+ schema do
10
+ required(:name).filled(:string)
11
+ required(:age).value(:integer)
12
+ end
13
+ end
14
+
15
+ class CreateUser
16
+ include Teckel::Operation
17
+
18
+ result!
19
+
20
+ input(NewUserContract.new)
21
+ input_constructor(->(input){
22
+ result = self.class.input.call(input)
23
+ if result.success?
24
+ result.to_h
25
+ else
26
+ fail!(message: "Input data validation failed", errors: [result.errors.to_h])
27
+ end
28
+ })
29
+
30
+ output Types.Instance(User)
31
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
32
+
33
+ def call(input)
34
+ user = User.new(name: input[:name], age: input[:age])
35
+ if user.save
36
+ success! user
37
+ else
38
+ fail!(message: "Could not save User", errors: user.errors)
39
+ end
40
+ end
41
+
42
+ finalize!
43
+ end
44
+
45
+ class CreateUserIncorrectFailure
46
+ include Teckel::Operation
47
+
48
+ result!
49
+
50
+ input(->(input) { input }) # NoOp
51
+ input_constructor(->(_input) {
52
+ fail!("Input data validation failed")
53
+ })
54
+
55
+ output none
56
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
57
+
58
+ def call(_); end
59
+ finalize!
60
+ end
61
+ end
62
+
63
+ RSpec.describe Teckel::Operation do
64
+ specify "successs" do
65
+ result = TeckelOperationFailOnOInput::CreateUser.call(name: "Bob", age: 23)
66
+ expect(result).to be_successful
67
+ expect(result.success).to be_a(User)
68
+ end
69
+
70
+ describe "failing in input_constructor" do
71
+ let(:failure_input) do
72
+ { name: "", age: "incorrect type" }
73
+ end
74
+
75
+ it "returns the failure thrown in input_constructor" do
76
+ result = TeckelOperationFailOnOInput::CreateUser.call(failure_input)
77
+ expect(result).to be_a(Teckel::Operation::Result)
78
+ expect(result).to be_failure
79
+ expect(result.failure).to eq(
80
+ message: "Input data validation failed",
81
+ errors: [
82
+ { name: ["must be filled"], age: ["must be an integer"] }
83
+ ]
84
+ )
85
+ end
86
+
87
+ it "does not run .call" do
88
+ expect(TeckelOperationFailOnOInput::CreateUser).to receive(:new).and_wrap_original do |m, *args|
89
+ op_instance = m.call(*args)
90
+ expect(op_instance).to_not receive(:call)
91
+ op_instance
92
+ end
93
+
94
+ TeckelOperationFailOnOInput::CreateUser.call(failure_input)
95
+ end
96
+ end
97
+
98
+ specify "thrown failure needs to conform to :error" do
99
+ expect {
100
+ TeckelOperationFailOnOInput::CreateUserIncorrectFailure.call(name: "Bob", age: 23)
101
+ }.to raise_error(Dry::Types::ConstraintError, /violates constraints/)
102
+ end
103
+ end
@@ -1,57 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- RSpec.describe Teckel::Operation do
4
- context "default settings via base class" do
5
- module TeckelOperationDefaultsViaBaseClass
6
- DefaultError = Struct.new(:message, :status_code)
7
- Settings = Struct.new(:fail_it)
8
-
9
- class ApplicationOperation
10
- include Teckel::Operation
3
+ module TeckelOperationDefaultsViaBaseClass
4
+ DefaultError = Struct.new(:message, :status_code)
5
+ Settings = Struct.new(:fail_it)
11
6
 
12
- settings Settings
13
- settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
7
+ class ApplicationOperation
8
+ include Teckel::Operation
14
9
 
15
- error DefaultError
16
- error_constructor ->(data) { error.new(*data.values_at(*error.members)) }
10
+ settings Settings
11
+ settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
17
12
 
18
- result!
13
+ error DefaultError
14
+ error_constructor ->(data) { error.new(*data.values_at(*error.members)) }
19
15
 
20
- # Freeze the base class to make sure it's inheritable configuration is not altered
21
- freeze
22
- end
16
+ result!
23
17
 
24
- class OperationA < ApplicationOperation
25
- input Struct.new(:input_data_a)
26
- output Struct.new(:output_data_a)
18
+ # Freeze the base class to make sure it's inheritable configuration is not altered
19
+ freeze
20
+ end
27
21
 
28
- def call(input)
29
- if settings&.fail_it
30
- fail!(message: settings.fail_it, status_code: 400)
31
- else
32
- input.input_data_a * 2
33
- end
34
- end
22
+ class OperationA < ApplicationOperation
23
+ input Struct.new(:input_data_a)
24
+ output Struct.new(:output_data_a)
35
25
 
36
- finalize!
26
+ def call(input)
27
+ if settings&.fail_it
28
+ fail!(message: settings.fail_it, status_code: 400)
29
+ else
30
+ success!(input.input_data_a * 2)
37
31
  end
32
+ end
38
33
 
39
- class OperationB < ApplicationOperation
40
- input Struct.new(:input_data_b)
41
- output Struct.new(:output_data_b)
34
+ finalize!
35
+ end
42
36
 
43
- def call(input)
44
- if settings&.fail_it
45
- fail!(message: settings.fail_it, status_code: 500)
46
- else
47
- input.input_data_b * 4
48
- end
49
- end
37
+ class OperationB < ApplicationOperation
38
+ input Struct.new(:input_data_b)
39
+ output Struct.new(:output_data_b)
50
40
 
51
- finalize!
41
+ def call(input)
42
+ if settings&.fail_it
43
+ fail!(message: settings.fail_it, status_code: 500)
44
+ else
45
+ success!(input.input_data_b * 4)
52
46
  end
53
47
  end
54
48
 
49
+ finalize!
50
+ end
51
+ end
52
+
53
+ RSpec.describe Teckel::Operation do
54
+ context "default settings via base class" do
55
55
  let(:operation_a) { TeckelOperationDefaultsViaBaseClass::OperationA }
56
56
  let(:operation_b) { TeckelOperationDefaultsViaBaseClass::OperationB }
57
57