dexkit 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.
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module TestHelpers
5
+ # --- Result assertions ---
6
+
7
+ def assert_ok(result, expected = :_not_given, msg = nil, &block)
8
+ assert result.ok?, msg || "Expected Ok, got Err:\n#{_dex_format_err(result)}"
9
+ if expected != :_not_given
10
+ assert_equal expected, result.value, msg || "Ok value mismatch"
11
+ end
12
+ yield result.value if block
13
+ result
14
+ end
15
+
16
+ def refute_ok(result, msg = nil)
17
+ refute result.ok?, msg || "Expected Err, got Ok:\n#{_dex_format_ok(result)}"
18
+ result
19
+ end
20
+
21
+ def assert_err(result, code = nil, message: nil, details: nil, msg: nil, &block)
22
+ assert result.error?, msg || "Expected Err, got Ok:\n#{_dex_format_ok(result)}"
23
+ if code
24
+ assert_equal code, result.code, msg || "Error code mismatch.\n#{_dex_format_err(result)}"
25
+ end
26
+ if message
27
+ case message
28
+ when Regexp
29
+ assert_match message, result.message, msg || "Error message mismatch.\n#{_dex_format_err(result)}"
30
+ else
31
+ assert_equal message, result.message, msg || "Error message mismatch.\n#{_dex_format_err(result)}"
32
+ end
33
+ end
34
+ details&.each do |key, val|
35
+ assert_equal val, result.details&.dig(key),
36
+ msg || "Error details[:#{key}] mismatch.\n#{_dex_format_err(result)}"
37
+ end
38
+ yield result.error if block
39
+ result
40
+ end
41
+
42
+ def refute_err(result, code = nil, msg: nil)
43
+ if code
44
+ if result.error?
45
+ refute_equal code, result.code,
46
+ msg || "Expected result to not have error code #{code.inspect}, but it does.\n#{_dex_format_err(result)}"
47
+ end
48
+ else
49
+ refute result.error?, msg || "Expected Ok, got Err:\n#{_dex_format_err(result)}"
50
+ end
51
+ result
52
+ end
53
+
54
+ # --- One-liner assertions ---
55
+
56
+ def assert_operation(*args, returns: :_not_given, **params)
57
+ klass = _dex_resolve_subject(args)
58
+ result = klass.new(**params).safe.call
59
+ assert result.ok?, "Expected operation to succeed, got Err:\n#{_dex_format_err(result)}"
60
+ if returns != :_not_given
61
+ assert_equal returns, result.value, "Return value mismatch"
62
+ end
63
+ result
64
+ end
65
+
66
+ def assert_operation_error(*args, message: nil, details: nil, **params)
67
+ klass, code = _dex_resolve_subject_and_code(args)
68
+ result = klass.new(**params).safe.call
69
+ assert result.error?, "Expected operation to fail, got Ok:\n#{_dex_format_ok(result)}"
70
+ if code
71
+ assert_equal code, result.code, "Error code mismatch.\n#{_dex_format_err(result)}"
72
+ end
73
+ if message
74
+ case message
75
+ when Regexp
76
+ assert_match message, result.message
77
+ else
78
+ assert_equal message, result.message
79
+ end
80
+ end
81
+ details&.each do |key, val|
82
+ assert_equal val, result.details&.dig(key)
83
+ end
84
+ result
85
+ end
86
+
87
+ # --- Contract assertions ---
88
+
89
+ def assert_params(*args)
90
+ if args.last.is_a?(Hash)
91
+ klass_args, type_hash = _dex_split_class_and_hash(args)
92
+ klass = _dex_resolve_subject(klass_args)
93
+ contract = klass.contract
94
+ type_hash.each do |name, type|
95
+ assert contract.params.key?(name),
96
+ "Expected param #{name.inspect} to be declared on #{klass.name || klass}"
97
+ assert_equal type, contract.params[name],
98
+ "Type mismatch for param #{name.inspect}"
99
+ end
100
+ else
101
+ klass_args, names = _dex_split_class_and_symbols(args)
102
+ klass = _dex_resolve_subject(klass_args)
103
+ contract = klass.contract
104
+ assert_equal names.sort, contract.params.keys.sort,
105
+ "Params mismatch on #{klass.name || klass}.\n Expected: #{names.sort.inspect}\n Actual: #{contract.params.keys.sort.inspect}"
106
+ end
107
+ end
108
+
109
+ def assert_accepts_param(*args)
110
+ klass_args, names = _dex_split_class_and_symbols(args)
111
+ klass = _dex_resolve_subject(klass_args)
112
+ contract = klass.contract
113
+ names.each do |name|
114
+ assert contract.params.key?(name),
115
+ "Expected #{klass.name || klass} to accept param #{name.inspect}, but it doesn't.\n Declared params: #{contract.params.keys.inspect}"
116
+ end
117
+ end
118
+
119
+ def assert_success_type(*args)
120
+ klass = if args.first.is_a?(Class) && args.first < Dex::Operation
121
+ args.shift
122
+ else
123
+ _dex_resolve_subject([])
124
+ end
125
+ expected = args.first
126
+ contract = klass.contract
127
+ assert_equal expected, contract.success,
128
+ "Success type mismatch on #{klass.name || klass}"
129
+ end
130
+
131
+ def assert_error_codes(*args)
132
+ klass_args, codes = _dex_split_class_and_symbols(args)
133
+ klass = _dex_resolve_subject(klass_args)
134
+ contract = klass.contract
135
+ assert_equal codes.sort, contract.errors.sort,
136
+ "Error codes mismatch on #{klass.name || klass}.\n Expected: #{codes.sort.inspect}\n Actual: #{contract.errors.sort.inspect}"
137
+ end
138
+
139
+ def assert_contract(*args, params: nil, success: :_not_given, errors: nil)
140
+ klass = _dex_resolve_subject(args)
141
+ contract = klass.contract
142
+
143
+ if params
144
+ case params
145
+ when Array
146
+ assert_equal params.sort, contract.params.keys.sort, "Contract params mismatch"
147
+ when Hash
148
+ params.each do |name, type|
149
+ assert contract.params.key?(name), "Expected param #{name.inspect}"
150
+ assert_equal type, contract.params[name], "Type mismatch for param #{name.inspect}"
151
+ end
152
+ end
153
+ end
154
+
155
+ if success != :_not_given
156
+ assert_equal success, contract.success, "Contract success type mismatch"
157
+ end
158
+
159
+ if errors
160
+ assert_equal errors.sort, contract.errors.sort, "Contract error codes mismatch"
161
+ end
162
+ end
163
+
164
+ # --- Param validation assertions ---
165
+
166
+ def assert_invalid_params(*args, **params)
167
+ klass = _dex_resolve_subject(args)
168
+ assert_raises(Literal::TypeError) { klass.new(**params) }
169
+ end
170
+
171
+ def assert_valid_params(*args, **params)
172
+ klass = _dex_resolve_subject(args)
173
+ klass.new(**params)
174
+ end
175
+
176
+ # --- Async assertions ---
177
+
178
+ def assert_enqueues_operation(*args, queue: nil, **params)
179
+ _dex_ensure_active_job_test_helper!
180
+ klass = _dex_resolve_subject(args)
181
+ async_opts = queue ? { queue: queue } : {}
182
+ before_count = enqueued_jobs.size
183
+ klass.new(**params).async(**async_opts).call
184
+ new_jobs = enqueued_jobs[before_count..]
185
+ dex_job = new_jobs.find { |j|
186
+ j[:job] == Dex::Operation::DirectJob || j[:job] == Dex::Operation::RecordJob
187
+ }
188
+ assert dex_job,
189
+ "Expected #{klass.name || klass} to enqueue an async job, but none were enqueued"
190
+ end
191
+
192
+ def refute_enqueues_operation(&block)
193
+ _dex_ensure_active_job_test_helper!
194
+ before_count = enqueued_jobs.size
195
+ yield
196
+ after_count = enqueued_jobs.size
197
+ assert_equal before_count, after_count,
198
+ "Expected no operations to be enqueued, but #{after_count - before_count} were"
199
+ end
200
+
201
+ # --- Transaction assertions ---
202
+
203
+ def assert_rolls_back(model_class, &block)
204
+ count_before = model_class.count
205
+ assert_raises(Dex::Error) { yield }
206
+ assert_equal count_before, model_class.count,
207
+ "Expected transaction to roll back, but #{model_class.name} count changed from #{count_before} to #{model_class.count}"
208
+ end
209
+
210
+ def assert_commits(model_class, &block)
211
+ count_before = model_class.count
212
+ yield
213
+ assert count_before < model_class.count,
214
+ "Expected #{model_class.name} count to increase, but it stayed at #{count_before}"
215
+ end
216
+
217
+ # --- Batch assertions ---
218
+
219
+ def assert_all_succeed(*args, params_list:)
220
+ klass = _dex_resolve_subject(args)
221
+ results = params_list.map { |p| klass.new(**p).safe.call }
222
+ failures = results.each_with_index.reject { |r, _| r.ok? }
223
+ if failures.any?
224
+ msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
225
+ flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
226
+ end
227
+ results
228
+ end
229
+
230
+ def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
231
+ klass = _dex_resolve_subject(args)
232
+ results = params_list.map { |p| klass.new(**p).safe.call }
233
+ failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
234
+ if failures.any?
235
+ msgs = failures.map { |r, i|
236
+ status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
237
+ " [#{i}] #{params_list[i].inspect} => #{status}"
238
+ }
239
+ flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
240
+ end
241
+ results.each_with_index do |r, i|
242
+ if message
243
+ case message
244
+ when Regexp
245
+ assert_match message, r.message,
246
+ "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
247
+ else
248
+ assert_equal message, r.message,
249
+ "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
250
+ end
251
+ end
252
+ details&.each do |key, val|
253
+ assert_equal val, r.details&.dig(key),
254
+ "Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
255
+ end
256
+ end
257
+ results
258
+ end
259
+
260
+ private
261
+
262
+ def _dex_format_err(result)
263
+ return "(not an error)" unless result.respond_to?(:error?) && result.error?
264
+
265
+ lines = [" code: #{result.code.inspect}"]
266
+ lines << " message: #{result.message.inspect}" if result.message && result.message != result.code.to_s
267
+ lines << " details: #{result.details.inspect}" if result.details
268
+ lines.join("\n")
269
+ end
270
+
271
+ def _dex_format_ok(result)
272
+ return "(not ok)" unless result.respond_to?(:ok?) && result.ok?
273
+
274
+ " value: #{result.value.inspect}"
275
+ end
276
+
277
+ def _dex_resolve_subject_and_code(args)
278
+ if args.first.is_a?(Class) && args.first < Dex::Operation
279
+ klass = args.shift
280
+ code = args.shift
281
+ [klass, code]
282
+ elsif args.first.is_a?(Symbol)
283
+ [_dex_resolve_subject([]), args.shift]
284
+ else
285
+ [_dex_resolve_subject([]), nil]
286
+ end
287
+ end
288
+
289
+ def _dex_split_class_and_symbols(args)
290
+ if args.first.is_a?(Class) && args.first < Dex::Operation
291
+ [args[0..0], args[1..]]
292
+ else
293
+ [[], args]
294
+ end
295
+ end
296
+
297
+ def _dex_split_class_and_hash(args)
298
+ hash = args.pop
299
+ klass_args = args.select { |a| a.is_a?(Class) && a < Dex::Operation }
300
+ [klass_args, hash]
301
+ end
302
+
303
+ def _dex_ensure_active_job_test_helper!
304
+ return if respond_to?(:assert_enqueued_with)
305
+
306
+ raise "assert_enqueues_operation requires ActiveJob::TestHelper. " \
307
+ "Include it in your test class: `include ActiveJob::TestHelper`"
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module TestHelpers
5
+ def call_operation(*args, **params)
6
+ klass = _dex_resolve_subject(args)
7
+ klass.new(**params).safe.call
8
+ end
9
+
10
+ def call_operation!(*args, **params)
11
+ klass = _dex_resolve_subject(args)
12
+ klass.new(**params).call
13
+ end
14
+
15
+ private
16
+
17
+ def _dex_resolve_subject(args)
18
+ if args.first.is_a?(Class) && args.first < Dex::Operation
19
+ args.first
20
+ elsif _dex_test_subject
21
+ _dex_test_subject
22
+ else
23
+ raise ArgumentError,
24
+ "No operation class specified. Pass it as the first argument or use `testing MyOperation` in your test class."
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module TestHelpers
5
+ def stub_operation(klass, returns: nil, error: nil, &block)
6
+ raise ArgumentError, "stub_operation requires a block" unless block
7
+
8
+ opts = if error
9
+ { error: error }
10
+ else
11
+ { returns: returns }
12
+ end
13
+
14
+ Dex::TestWrapper.register_stub(klass, **opts)
15
+ yield
16
+ ensure
17
+ Dex::TestWrapper.clear_stub(klass)
18
+ end
19
+
20
+ def spy_on_operation(klass, &block)
21
+ spy = Spy.new(klass)
22
+ yield spy
23
+ spy
24
+ end
25
+
26
+ class Spy
27
+ def initialize(klass)
28
+ @klass = klass
29
+ @started_at = Dex::TestLog.size
30
+ end
31
+
32
+ def calls
33
+ Dex::TestLog.calls[@started_at..].select { |e| e.operation_class == @klass }
34
+ end
35
+
36
+ def called?
37
+ calls.any?
38
+ end
39
+
40
+ def called_once?
41
+ calls.size == 1
42
+ end
43
+
44
+ def call_count
45
+ calls.size
46
+ end
47
+
48
+ def last_result
49
+ calls.last&.result
50
+ end
51
+
52
+ def called_with?(**params)
53
+ calls.any? do |entry|
54
+ params.all? { |k, v| entry.params[k] == v }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_log"
4
+
5
+ module Dex
6
+ module TestWrapper
7
+ @_installed = false
8
+
9
+ class << self
10
+ def install!
11
+ return if @_installed
12
+
13
+ Dex::Operation.prepend(self)
14
+ @_installed = true
15
+ end
16
+
17
+ def installed?
18
+ @_installed
19
+ end
20
+
21
+ # Stub registry
22
+
23
+ def stubs
24
+ @_stubs ||= {}
25
+ end
26
+
27
+ def find_stub(klass)
28
+ stubs[klass]
29
+ end
30
+
31
+ def register_stub(klass, **options)
32
+ stubs[klass] = options
33
+ end
34
+
35
+ def clear_stub(klass)
36
+ stubs.delete(klass)
37
+ end
38
+
39
+ def clear_all_stubs!
40
+ stubs.clear
41
+ end
42
+ end
43
+
44
+ def call
45
+ stub = Dex::TestWrapper.find_stub(self.class)
46
+ return _test_apply_stub(stub) if stub
47
+
48
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
+ result = nil
50
+ err = nil
51
+
52
+ begin
53
+ result = super
54
+ rescue Exception => e # rubocop:disable Lint/RescueException
55
+ err = e
56
+ raise
57
+ ensure
58
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
59
+ _test_record_to_log(result, err, duration)
60
+ end
61
+
62
+ result
63
+ end
64
+
65
+ private
66
+
67
+ def _test_apply_stub(stub)
68
+ if stub[:error]
69
+ err_opts = stub[:error]
70
+ case err_opts
71
+ when Symbol
72
+ raise Dex::Error.new(err_opts)
73
+ when Hash
74
+ raise Dex::Error.new(err_opts[:code], err_opts[:message], details: err_opts[:details])
75
+ end
76
+ else
77
+ stub[:returns]
78
+ end
79
+ end
80
+
81
+ def _test_safe_params
82
+ respond_to?(:to_h) ? to_h : {}
83
+ rescue
84
+ {}
85
+ end
86
+
87
+ def _test_record_to_log(result, err, duration)
88
+ safe_result = if err
89
+ dex_err = if err.is_a?(Dex::Error)
90
+ err
91
+ else
92
+ Dex::Error.new(:exception, err.message, details: { exception_class: err.class.name })
93
+ end
94
+ Dex::Operation::Err.new(dex_err)
95
+ else
96
+ Dex::Operation::Ok.new(result)
97
+ end
98
+
99
+ entry = Dex::TestLog::Entry.new(
100
+ type: "Operation",
101
+ name: self.class.name || self.class.to_s,
102
+ operation_class: self.class,
103
+ params: _test_safe_params,
104
+ result: safe_result,
105
+ duration: duration,
106
+ caller_location: caller_locations(4, 1)&.first
107
+ )
108
+ Dex::TestLog.record(entry)
109
+ end
110
+ end
111
+
112
+ module TestHelpers
113
+ def self.included(base)
114
+ Dex::TestWrapper.install!
115
+ base.extend(ClassMethods)
116
+ end
117
+
118
+ def setup
119
+ super
120
+ Dex::TestLog.clear!
121
+ Dex::TestWrapper.clear_all_stubs!
122
+ end
123
+
124
+ module ClassMethods
125
+ def testing(klass)
126
+ @_dex_test_subject = klass
127
+ end
128
+
129
+ def _dex_test_subject
130
+ return @_dex_test_subject if defined?(@_dex_test_subject) && @_dex_test_subject
131
+
132
+ superclass._dex_test_subject if superclass.respond_to?(:_dex_test_subject)
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def _dex_test_subject
139
+ self.class._dex_test_subject
140
+ end
141
+ end
142
+ end
143
+
144
+ require_relative "test_helpers/execution"
145
+ require_relative "test_helpers/assertions"
146
+ require_relative "test_helpers/stubbing"
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module TestLog
5
+ Entry = Data.define(:type, :name, :operation_class, :params, :result, :duration, :caller_location)
6
+
7
+ @_entries = []
8
+ @_mutex = Mutex.new
9
+
10
+ class << self
11
+ def record(entry)
12
+ @_mutex.synchronize { @_entries << entry }
13
+ end
14
+
15
+ def calls
16
+ @_mutex.synchronize { @_entries.dup }
17
+ end
18
+
19
+ def clear!
20
+ @_mutex.synchronize { @_entries.clear }
21
+ end
22
+
23
+ def size
24
+ @_mutex.synchronize { @_entries.size }
25
+ end
26
+
27
+ def empty?
28
+ @_mutex.synchronize { @_entries.empty? }
29
+ end
30
+
31
+ def find(klass, **params)
32
+ @_mutex.synchronize do
33
+ @_entries.select do |entry|
34
+ next false unless entry.operation_class == klass
35
+ params.all? { |k, v| entry.params[k] == v }
36
+ end
37
+ end
38
+ end
39
+
40
+ def summary
41
+ entries = calls
42
+ return "No operations called." if entries.empty?
43
+
44
+ lines = ["Operations called (#{entries.size}):"]
45
+ entries.each_with_index do |entry, i|
46
+ status = entry.result.ok? ? "OK" : "ERR(#{entry.result.code})"
47
+ duration_ms = entry.duration ? format("%.1fms", entry.duration * 1000) : "n/a"
48
+ lines << " #{i + 1}. #{entry.name} [#{status}] #{duration_ms}"
49
+ lines << " params: #{entry.params.inspect}" unless entry.params.nil? || entry.params.empty?
50
+ end
51
+ lines.join("\n")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ VERSION = "0.1.0"
5
+ end
data/lib/dexkit.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ require "literal"
6
+ require "time"
7
+
8
+ loader = Zeitwerk::Loader.for_gem
9
+ loader.ignore("#{__dir__}/dex")
10
+ loader.setup
11
+
12
+ require_relative "dex/version"
13
+ require_relative "dex/ref_type"
14
+ require_relative "dex/error"
15
+ require_relative "dex/operation"
16
+
17
+ module Dex
18
+ class Configuration
19
+ attr_accessor :record_class, :transaction_adapter
20
+
21
+ def initialize
22
+ @record_class = nil
23
+ @transaction_adapter = nil
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def configuration
29
+ @configuration ||= Configuration.new
30
+ end
31
+
32
+ def configure
33
+ yield(configuration)
34
+ end
35
+
36
+ def record_class
37
+ configuration.record_class
38
+ end
39
+
40
+ def record_backend
41
+ return @record_backend if defined?(@record_backend)
42
+ @record_backend = Operation::RecordBackend.for(record_class)
43
+ end
44
+
45
+ def reset_record_backend!
46
+ remove_instance_variable(:@record_backend) if defined?(@record_backend)
47
+ end
48
+
49
+ def transaction_adapter
50
+ configuration.transaction_adapter
51
+ end
52
+
53
+ def transaction_adapter=(adapter)
54
+ configuration.transaction_adapter = adapter
55
+ end
56
+ end
57
+ end