dexkit 0.8.0 → 0.10.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/CHANGELOG.md +36 -0
- data/README.md +50 -18
- data/gemfiles/mongoid_no_ar.gemfile +10 -0
- data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
- data/guides/llm/EVENT.md +41 -23
- data/guides/llm/FORM.md +202 -61
- data/guides/llm/OPERATION.md +49 -20
- data/guides/llm/QUERY.md +52 -2
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +85 -8
- data/lib/dex/event/handler.rb +18 -0
- data/lib/dex/event/metadata.rb +16 -9
- data/lib/dex/event/processor.rb +1 -1
- data/lib/dex/event/test_helpers.rb +88 -0
- data/lib/dex/event/trace.rb +14 -27
- data/lib/dex/event.rb +2 -7
- data/lib/dex/event_test_helpers.rb +1 -86
- data/lib/dex/form/context.rb +27 -0
- data/lib/dex/form/export.rb +128 -0
- data/lib/dex/form/nesting.rb +2 -0
- data/lib/dex/form/uniqueness_validator.rb +17 -1
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +38 -0
- data/lib/dex/operation/async_proxy.rb +13 -2
- data/lib/dex/operation/explain.rb +11 -7
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/lock_wrapper.rb +15 -2
- data/lib/dex/operation/once_wrapper.rb +24 -15
- data/lib/dex/operation/record_backend.rb +15 -1
- data/lib/dex/operation/record_wrapper.rb +43 -8
- data/lib/dex/operation/test_helpers/assertions.rb +359 -0
- data/lib/dex/operation/test_helpers/execution.rb +30 -0
- data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
- data/lib/dex/operation/test_helpers.rb +160 -0
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation/transaction_adapter.rb +29 -68
- data/lib/dex/operation/transaction_wrapper.rb +10 -16
- data/lib/dex/operation.rb +2 -0
- data/lib/dex/query/backend.rb +13 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +50 -5
- data/lib/dex/ref_type.rb +4 -0
- data/lib/dex/test_helpers.rb +4 -139
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/type_coercion.rb +4 -1
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +9 -5
- metadata +16 -5
- data/lib/dex/test_helpers/assertions.rb +0 -333
- data/lib/dex/test_helpers/execution.rb +0 -28
- data/lib/dex/test_helpers/stubbing.rb +0 -59
- /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
|
@@ -5,6 +5,7 @@ module Dex
|
|
|
5
5
|
extend Dex::Concern
|
|
6
6
|
|
|
7
7
|
def _record_wrap
|
|
8
|
+
_record_validate_backend! if _record_enabled? || _record_has_pending_record?
|
|
8
9
|
interceptor = Operation::HaltInterceptor.new { yield }
|
|
9
10
|
|
|
10
11
|
if _record_has_pending_record?
|
|
@@ -34,6 +35,14 @@ module Dex
|
|
|
34
35
|
"record expects true, false, or nil, got: #{enabled.inspect}"
|
|
35
36
|
end
|
|
36
37
|
end
|
|
38
|
+
|
|
39
|
+
def _record_required_fields(async: false)
|
|
40
|
+
settings = settings_for(:record)
|
|
41
|
+
fields = %w[name status performed_at error_code error_message error_details]
|
|
42
|
+
fields << "params" if async || settings.fetch(:params, true)
|
|
43
|
+
fields << "result" if settings.fetch(:result, true)
|
|
44
|
+
fields
|
|
45
|
+
end
|
|
37
46
|
end
|
|
38
47
|
|
|
39
48
|
private
|
|
@@ -46,6 +55,13 @@ module Dex
|
|
|
46
55
|
record_settings.fetch(:enabled, true)
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
def _record_validate_backend!(async: false)
|
|
59
|
+
Dex.record_backend.ensure_fields!(
|
|
60
|
+
self.class.send(:_record_required_fields, async: async),
|
|
61
|
+
feature: async ? "async recording" : "operation recording"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
49
65
|
def _record_has_pending_record?
|
|
50
66
|
defined?(@_dex_record_id) && @_dex_record_id
|
|
51
67
|
end
|
|
@@ -72,7 +88,7 @@ module Dex
|
|
|
72
88
|
else
|
|
73
89
|
_record_success_attrs(interceptor.result)
|
|
74
90
|
end
|
|
75
|
-
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
91
|
+
Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
|
|
76
92
|
rescue => e
|
|
77
93
|
_record_handle_error(e)
|
|
78
94
|
end
|
|
@@ -92,7 +108,7 @@ module Dex
|
|
|
92
108
|
attrs[:once_key] = nil if defined?(@_once_key) || self.class.settings_for(:once).fetch(:defined, false)
|
|
93
109
|
|
|
94
110
|
if _record_has_pending_record?
|
|
95
|
-
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
111
|
+
Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
|
|
96
112
|
else
|
|
97
113
|
Dex.record_backend.create_record(_record_base_attrs.merge(attrs))
|
|
98
114
|
end
|
|
@@ -100,8 +116,18 @@ module Dex
|
|
|
100
116
|
_record_handle_error(e)
|
|
101
117
|
end
|
|
102
118
|
|
|
103
|
-
def _record_base_attrs
|
|
104
|
-
|
|
119
|
+
def _record_base_attrs(include_id: true)
|
|
120
|
+
trace_snapshot = Dex::Trace.current
|
|
121
|
+
actor_frame = Dex::Trace.actor
|
|
122
|
+
|
|
123
|
+
attrs = {
|
|
124
|
+
name: self.class.name,
|
|
125
|
+
trace_id: Dex::Trace.trace_id,
|
|
126
|
+
trace: trace_snapshot,
|
|
127
|
+
actor_type: actor_frame&.dig(:actor_type),
|
|
128
|
+
actor_id: actor_frame&.dig(:id)&.to_s
|
|
129
|
+
}
|
|
130
|
+
attrs[:id] = @_dex_execution_id if include_id && defined?(@_dex_execution_id) && @_dex_execution_id
|
|
105
131
|
attrs[:params] = _record_params? ? _record_params : nil
|
|
106
132
|
attrs
|
|
107
133
|
end
|
|
@@ -142,8 +168,8 @@ module Dex
|
|
|
142
168
|
else
|
|
143
169
|
case result
|
|
144
170
|
when nil then nil
|
|
145
|
-
when Hash then result
|
|
146
|
-
else { _dex_value
|
|
171
|
+
when Hash then _record_sanitize_value(result)
|
|
172
|
+
else { "_dex_value" => _record_sanitize_value(result) } # namespaced key so replay can distinguish wrapped primitives from user hashes
|
|
147
173
|
end
|
|
148
174
|
end
|
|
149
175
|
end
|
|
@@ -160,10 +186,19 @@ module Dex
|
|
|
160
186
|
case value
|
|
161
187
|
when NilClass, String, Integer, Float, TrueClass, FalseClass then value
|
|
162
188
|
when Symbol then value.to_s
|
|
163
|
-
when Hash
|
|
189
|
+
when Hash
|
|
190
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
191
|
+
result[key.to_s] = _record_sanitize_value(nested_value)
|
|
192
|
+
end
|
|
164
193
|
when Array then value.map { |v| _record_sanitize_value(v) }
|
|
165
194
|
when Exception then "#{value.class}: #{value.message}"
|
|
166
|
-
else
|
|
195
|
+
else
|
|
196
|
+
if value.respond_to?(:as_json)
|
|
197
|
+
serialized = value.as_json
|
|
198
|
+
return _record_sanitize_value(serialized) unless serialized.equal?(value)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
value.to_s
|
|
167
202
|
end
|
|
168
203
|
end
|
|
169
204
|
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module TestHelpers
|
|
6
|
+
# --- Result assertions ---
|
|
7
|
+
|
|
8
|
+
def assert_ok(result, expected = :_not_given, msg = nil, &block)
|
|
9
|
+
assert result.ok?, msg || "Expected Ok, got Err:\n#{_dex_format_err(result)}"
|
|
10
|
+
if expected != :_not_given
|
|
11
|
+
assert_equal expected, result.value, msg || "Ok value mismatch"
|
|
12
|
+
end
|
|
13
|
+
yield result.value if block
|
|
14
|
+
result
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def refute_ok(result, msg = nil)
|
|
18
|
+
refute result.ok?, msg || "Expected Err, got Ok:\n#{_dex_format_ok(result)}"
|
|
19
|
+
result
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def assert_err(result, code = nil, message: nil, details: nil, msg: nil, &block)
|
|
23
|
+
assert result.error?, msg || "Expected Err, got Ok:\n#{_dex_format_ok(result)}"
|
|
24
|
+
if code
|
|
25
|
+
assert_equal code, result.code, msg || "Error code mismatch.\n#{_dex_format_err(result)}"
|
|
26
|
+
end
|
|
27
|
+
if message
|
|
28
|
+
case message
|
|
29
|
+
when Regexp
|
|
30
|
+
assert_match message, result.message, msg || "Error message mismatch.\n#{_dex_format_err(result)}"
|
|
31
|
+
else
|
|
32
|
+
assert_equal message, result.message, msg || "Error message mismatch.\n#{_dex_format_err(result)}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
details&.each do |key, val|
|
|
36
|
+
assert_equal val, result.details&.dig(key),
|
|
37
|
+
msg || "Error details[:#{key}] mismatch.\n#{_dex_format_err(result)}"
|
|
38
|
+
end
|
|
39
|
+
yield result.error if block
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def refute_err(result, code = nil, msg: nil)
|
|
44
|
+
if code
|
|
45
|
+
if result.error?
|
|
46
|
+
refute_equal code, result.code,
|
|
47
|
+
msg || "Expected result to not have error code #{code.inspect}, but it does.\n#{_dex_format_err(result)}"
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
refute result.error?, msg || "Expected Ok, got Err:\n#{_dex_format_err(result)}"
|
|
51
|
+
end
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- One-liner assertions ---
|
|
56
|
+
|
|
57
|
+
def assert_operation(*args, returns: :_not_given, **params)
|
|
58
|
+
klass = _dex_resolve_subject(args)
|
|
59
|
+
result = klass.new(**params).safe.call
|
|
60
|
+
assert result.ok?, "Expected operation to succeed, got Err:\n#{_dex_format_err(result)}"
|
|
61
|
+
if returns != :_not_given
|
|
62
|
+
assert_equal returns, result.value, "Return value mismatch"
|
|
63
|
+
end
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def assert_operation_error(*args, message: nil, details: nil, **params)
|
|
68
|
+
klass, code = _dex_resolve_subject_and_code(args)
|
|
69
|
+
result = klass.new(**params).safe.call
|
|
70
|
+
assert result.error?, "Expected operation to fail, got Ok:\n#{_dex_format_ok(result)}"
|
|
71
|
+
if code
|
|
72
|
+
assert_equal code, result.code, "Error code mismatch.\n#{_dex_format_err(result)}"
|
|
73
|
+
end
|
|
74
|
+
if message
|
|
75
|
+
case message
|
|
76
|
+
when Regexp
|
|
77
|
+
assert_match message, result.message
|
|
78
|
+
else
|
|
79
|
+
assert_equal message, result.message
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
details&.each do |key, val|
|
|
83
|
+
assert_equal val, result.details&.dig(key)
|
|
84
|
+
end
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# --- Contract assertions ---
|
|
89
|
+
|
|
90
|
+
def assert_params(*args)
|
|
91
|
+
if args.last.is_a?(Hash)
|
|
92
|
+
klass_args, type_hash = _dex_split_class_and_hash(args)
|
|
93
|
+
klass = _dex_resolve_subject(klass_args)
|
|
94
|
+
contract = klass.contract
|
|
95
|
+
type_hash.each do |name, type|
|
|
96
|
+
assert contract.params.key?(name),
|
|
97
|
+
"Expected param #{name.inspect} to be declared on #{klass.name || klass}"
|
|
98
|
+
assert_equal type, contract.params[name],
|
|
99
|
+
"Type mismatch for param #{name.inspect}"
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
klass_args, names = _dex_split_class_and_symbols(args)
|
|
103
|
+
klass = _dex_resolve_subject(klass_args)
|
|
104
|
+
contract = klass.contract
|
|
105
|
+
assert_equal names.sort, contract.params.keys.sort,
|
|
106
|
+
"Params mismatch on #{klass.name || klass}.\n Expected: #{names.sort.inspect}\n Actual: #{contract.params.keys.sort.inspect}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def assert_accepts_param(*args)
|
|
111
|
+
klass_args, names = _dex_split_class_and_symbols(args)
|
|
112
|
+
klass = _dex_resolve_subject(klass_args)
|
|
113
|
+
contract = klass.contract
|
|
114
|
+
names.each do |name|
|
|
115
|
+
assert contract.params.key?(name),
|
|
116
|
+
"Expected #{klass.name || klass} to accept param #{name.inspect}, but it doesn't.\n Declared params: #{contract.params.keys.inspect}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def assert_success_type(*args)
|
|
121
|
+
klass = if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
122
|
+
args.shift
|
|
123
|
+
else
|
|
124
|
+
_dex_resolve_subject([])
|
|
125
|
+
end
|
|
126
|
+
expected = args.first
|
|
127
|
+
contract = klass.contract
|
|
128
|
+
assert_equal expected, contract.success,
|
|
129
|
+
"Success type mismatch on #{klass.name || klass}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def assert_error_codes(*args)
|
|
133
|
+
klass_args, codes = _dex_split_class_and_symbols(args)
|
|
134
|
+
klass = _dex_resolve_subject(klass_args)
|
|
135
|
+
contract = klass.contract
|
|
136
|
+
assert_equal codes.sort, contract.errors.sort,
|
|
137
|
+
"Error codes mismatch on #{klass.name || klass}.\n Expected: #{codes.sort.inspect}\n Actual: #{contract.errors.sort.inspect}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def assert_contract(*args, params: nil, success: :_not_given, errors: nil)
|
|
141
|
+
klass = _dex_resolve_subject(args)
|
|
142
|
+
contract = klass.contract
|
|
143
|
+
|
|
144
|
+
if params
|
|
145
|
+
case params
|
|
146
|
+
when Array
|
|
147
|
+
assert_equal params.sort, contract.params.keys.sort, "Contract params mismatch"
|
|
148
|
+
when Hash
|
|
149
|
+
params.each do |name, type|
|
|
150
|
+
assert contract.params.key?(name), "Expected param #{name.inspect}"
|
|
151
|
+
assert_equal type, contract.params[name], "Type mismatch for param #{name.inspect}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if success != :_not_given
|
|
157
|
+
assert_equal success, contract.success, "Contract success type mismatch"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if errors
|
|
161
|
+
assert_equal errors.sort, contract.errors.sort, "Contract error codes mismatch"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- Param validation assertions ---
|
|
166
|
+
|
|
167
|
+
def assert_invalid_params(*args, **params)
|
|
168
|
+
klass = _dex_resolve_subject(args)
|
|
169
|
+
assert_raises(Literal::TypeError) { klass.new(**params) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def assert_valid_params(*args, **params)
|
|
173
|
+
klass = _dex_resolve_subject(args)
|
|
174
|
+
klass.new(**params)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# --- Async assertions ---
|
|
178
|
+
|
|
179
|
+
def assert_enqueues_operation(*args, queue: nil, **params)
|
|
180
|
+
_dex_ensure_active_job_test_helper!
|
|
181
|
+
klass = _dex_resolve_subject(args)
|
|
182
|
+
async_opts = queue ? { queue: queue } : {}
|
|
183
|
+
before_count = enqueued_jobs.size
|
|
184
|
+
klass.new(**params).async(**async_opts).call
|
|
185
|
+
new_jobs = enqueued_jobs[before_count..]
|
|
186
|
+
dex_job = new_jobs.find { |j|
|
|
187
|
+
j[:job] == Dex::Operation::DirectJob || j[:job] == Dex::Operation::RecordJob
|
|
188
|
+
}
|
|
189
|
+
assert dex_job,
|
|
190
|
+
"Expected #{klass.name || klass} to enqueue an async job, but none were enqueued"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def refute_enqueues_operation(&block)
|
|
194
|
+
_dex_ensure_active_job_test_helper!
|
|
195
|
+
before_count = enqueued_jobs.size
|
|
196
|
+
yield
|
|
197
|
+
after_count = enqueued_jobs.size
|
|
198
|
+
assert_equal before_count, after_count,
|
|
199
|
+
"Expected no operations to be enqueued, but #{after_count - before_count} were"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# --- Trace assertions ---
|
|
203
|
+
|
|
204
|
+
def assert_trace_includes(operation_class, msg: nil)
|
|
205
|
+
expected = operation_class.is_a?(Class) ? operation_class.name : operation_class.to_s
|
|
206
|
+
trace_classes = Dex::Trace.current.map { |frame| frame[:class] }.compact
|
|
207
|
+
|
|
208
|
+
assert_includes trace_classes, expected,
|
|
209
|
+
msg || "Expected trace to include #{expected.inspect}, got #{trace_classes.inspect}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def assert_trace_actor(type:, id: :_not_given, msg: nil)
|
|
213
|
+
actor = Dex::Trace.actor
|
|
214
|
+
refute_nil actor, msg || "Expected a trace actor, but no actor is set"
|
|
215
|
+
assert_equal type.to_s, actor[:actor_type], msg || "Trace actor type mismatch"
|
|
216
|
+
assert_equal id.to_s, actor[:id], msg || "Trace actor id mismatch" unless id == :_not_given
|
|
217
|
+
actor
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def assert_trace_depth(expected, msg: nil)
|
|
221
|
+
actual = Dex::Trace.current.size
|
|
222
|
+
assert_equal expected, actual,
|
|
223
|
+
msg || "Expected trace depth #{expected}, got #{actual}.\nTrace: #{Dex::Trace.current.inspect}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# --- Transaction assertions ---
|
|
227
|
+
|
|
228
|
+
def assert_rolls_back(model_class, &block)
|
|
229
|
+
count_before = model_class.count
|
|
230
|
+
assert_raises(Dex::Error) { yield }
|
|
231
|
+
assert_equal count_before, model_class.count,
|
|
232
|
+
"Expected transaction to roll back, but #{model_class.name} count changed from #{count_before} to #{model_class.count}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def assert_commits(model_class, &block)
|
|
236
|
+
count_before = model_class.count
|
|
237
|
+
yield
|
|
238
|
+
assert count_before < model_class.count,
|
|
239
|
+
"Expected #{model_class.name} count to increase, but it stayed at #{count_before}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# --- Guard assertions ---
|
|
243
|
+
|
|
244
|
+
def assert_callable(*args, **params)
|
|
245
|
+
klass = _dex_resolve_subject(args)
|
|
246
|
+
result = klass.callable(**params)
|
|
247
|
+
assert result.ok?, "Expected operation to be callable, but guards failed:\n#{_dex_format_err(result)}"
|
|
248
|
+
result
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def refute_callable(*args, **params)
|
|
252
|
+
klass_args, codes = _dex_split_class_and_symbols(args)
|
|
253
|
+
klass = _dex_resolve_subject(klass_args)
|
|
254
|
+
code = codes.first
|
|
255
|
+
result = klass.callable(**params)
|
|
256
|
+
refute result.ok?, "Expected operation to NOT be callable, but all guards passed"
|
|
257
|
+
if code
|
|
258
|
+
failed_codes = result.details.map { |f| f[:guard] }
|
|
259
|
+
assert_includes failed_codes, code,
|
|
260
|
+
"Expected guard :#{code} to fail, but it didn't.\n Failed guards: #{failed_codes.inspect}"
|
|
261
|
+
end
|
|
262
|
+
result
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# --- Batch assertions ---
|
|
266
|
+
|
|
267
|
+
def assert_all_succeed(*args, params_list:)
|
|
268
|
+
klass = _dex_resolve_subject(args)
|
|
269
|
+
results = params_list.map { |p| klass.new(**p).safe.call }
|
|
270
|
+
failures = results.each_with_index.reject { |r, _| r.ok? }
|
|
271
|
+
if failures.any?
|
|
272
|
+
msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
|
|
273
|
+
flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
|
|
274
|
+
end
|
|
275
|
+
results
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
|
|
279
|
+
klass = _dex_resolve_subject(args)
|
|
280
|
+
results = params_list.map { |p| klass.new(**p).safe.call }
|
|
281
|
+
failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
|
|
282
|
+
if failures.any?
|
|
283
|
+
msgs = failures.map { |r, i|
|
|
284
|
+
status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
|
|
285
|
+
" [#{i}] #{params_list[i].inspect} => #{status}"
|
|
286
|
+
}
|
|
287
|
+
flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
|
|
288
|
+
end
|
|
289
|
+
results.each_with_index do |r, i|
|
|
290
|
+
if message
|
|
291
|
+
case message
|
|
292
|
+
when Regexp
|
|
293
|
+
assert_match message, r.message,
|
|
294
|
+
"Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
295
|
+
else
|
|
296
|
+
assert_equal message, r.message,
|
|
297
|
+
"Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
details&.each do |key, val|
|
|
301
|
+
assert_equal val, r.details&.dig(key),
|
|
302
|
+
"Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
results
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
private
|
|
309
|
+
|
|
310
|
+
def _dex_format_err(result)
|
|
311
|
+
return "(not an error)" unless result.respond_to?(:error?) && result.error?
|
|
312
|
+
|
|
313
|
+
lines = [" code: #{result.code.inspect}"]
|
|
314
|
+
lines << " message: #{result.message.inspect}" if result.message && result.message != result.code.to_s
|
|
315
|
+
lines << " details: #{result.details.inspect}" if result.details
|
|
316
|
+
lines.join("\n")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def _dex_format_ok(result)
|
|
320
|
+
return "(not ok)" unless result.respond_to?(:ok?) && result.ok?
|
|
321
|
+
|
|
322
|
+
" value: #{result.value.inspect}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def _dex_resolve_subject_and_code(args)
|
|
326
|
+
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
327
|
+
klass = args.shift
|
|
328
|
+
code = args.shift
|
|
329
|
+
[klass, code]
|
|
330
|
+
elsif args.first.is_a?(Symbol)
|
|
331
|
+
[_dex_resolve_subject([]), args.shift]
|
|
332
|
+
else
|
|
333
|
+
[_dex_resolve_subject([]), nil]
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def _dex_split_class_and_symbols(args)
|
|
338
|
+
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
339
|
+
[args[0..0], args[1..]]
|
|
340
|
+
else
|
|
341
|
+
[[], args]
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def _dex_split_class_and_hash(args)
|
|
346
|
+
hash = args.pop
|
|
347
|
+
klass_args = args.select { |a| a.is_a?(Class) && a < Dex::Operation }
|
|
348
|
+
[klass_args, hash]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def _dex_ensure_active_job_test_helper!
|
|
352
|
+
return if respond_to?(:assert_enqueued_with)
|
|
353
|
+
|
|
354
|
+
raise "assert_enqueues_operation requires ActiveJob::TestHelper. " \
|
|
355
|
+
"Include it in your test class: `include ActiveJob::TestHelper`"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module TestHelpers
|
|
6
|
+
def call_operation(*args, **params)
|
|
7
|
+
klass = _dex_resolve_subject(args)
|
|
8
|
+
klass.new(**params).safe.call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call_operation!(*args, **params)
|
|
12
|
+
klass = _dex_resolve_subject(args)
|
|
13
|
+
klass.new(**params).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def _dex_resolve_subject(args)
|
|
19
|
+
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
20
|
+
args.first
|
|
21
|
+
elsif _dex_test_subject
|
|
22
|
+
_dex_test_subject
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"No operation class specified. Pass it as the first argument or use `testing MyOperation` in your test class."
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module TestHelpers
|
|
6
|
+
def stub_operation(klass, returns: nil, error: nil, &block)
|
|
7
|
+
raise ArgumentError, "stub_operation requires a block" unless block
|
|
8
|
+
|
|
9
|
+
opts = if error
|
|
10
|
+
{ error: error }
|
|
11
|
+
else
|
|
12
|
+
{ returns: returns }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Dex::Operation::TestWrapper.register_stub(klass, **opts)
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
Dex::Operation::TestWrapper.clear_stub(klass)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def spy_on_operation(klass, &block)
|
|
22
|
+
spy = Spy.new(klass)
|
|
23
|
+
yield spy
|
|
24
|
+
spy
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Spy
|
|
28
|
+
def initialize(klass)
|
|
29
|
+
@klass = klass
|
|
30
|
+
@started_at = Dex::TestLog.size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def calls
|
|
34
|
+
Dex::TestLog.calls[@started_at..].select { |e| e.operation_class == @klass }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def called?
|
|
38
|
+
calls.any?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def called_once?
|
|
42
|
+
calls.size == 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def call_count
|
|
46
|
+
calls.size
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def last_result
|
|
50
|
+
calls.last&.result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def called_with?(**params)
|
|
54
|
+
calls.any? do |entry|
|
|
55
|
+
params.all? { |k, v| entry.params[k] == v }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|