dexkit 0.8.0 → 0.9.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +6 -2
  4. data/gemfiles/mongoid_no_ar.gemfile +10 -0
  5. data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
  6. data/guides/llm/EVENT.md +17 -4
  7. data/guides/llm/FORM.md +2 -2
  8. data/guides/llm/OPERATION.md +22 -17
  9. data/guides/llm/QUERY.md +2 -2
  10. data/lib/dex/event/bus.rb +7 -0
  11. data/lib/dex/event/test_helpers.rb +88 -0
  12. data/lib/dex/event_test_helpers.rb +1 -86
  13. data/lib/dex/form/uniqueness_validator.rb +17 -1
  14. data/lib/dex/operation/async_proxy.rb +1 -0
  15. data/lib/dex/operation/explain.rb +11 -7
  16. data/lib/dex/operation/lock_wrapper.rb +15 -2
  17. data/lib/dex/operation/once_wrapper.rb +23 -15
  18. data/lib/dex/operation/record_backend.rb +13 -0
  19. data/lib/dex/operation/record_wrapper.rb +29 -4
  20. data/lib/dex/operation/test_helpers/assertions.rb +335 -0
  21. data/lib/dex/operation/test_helpers/execution.rb +30 -0
  22. data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
  23. data/lib/dex/operation/test_helpers.rb +150 -0
  24. data/lib/dex/operation/transaction_adapter.rb +29 -68
  25. data/lib/dex/operation/transaction_wrapper.rb +10 -16
  26. data/lib/dex/query/backend.rb +13 -0
  27. data/lib/dex/query.rb +9 -5
  28. data/lib/dex/ref_type.rb +4 -0
  29. data/lib/dex/test_helpers.rb +4 -139
  30. data/lib/dex/type_coercion.rb +4 -1
  31. data/lib/dex/version.rb +1 -1
  32. data/lib/dexkit.rb +6 -5
  33. metadata +9 -5
  34. data/lib/dex/test_helpers/assertions.rb +0 -333
  35. data/lib/dex/test_helpers/execution.rb +0 -28
  36. data/lib/dex/test_helpers/stubbing.rb +0 -59
  37. /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
@@ -1,88 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dex
4
- class Event
5
- module EventTestWrapper
6
- CAPTURING_KEY = :_dex_event_capturing
7
- PUBLISHED_KEY = :_dex_event_published
8
-
9
- @_installed = false
10
-
11
- class << self
12
- include ExecutionState
13
-
14
- def install!
15
- return if @_installed
16
-
17
- Dex::Event::Bus.singleton_class.prepend(BusInterceptor)
18
- @_installed = true
19
- end
20
-
21
- def installed?
22
- @_installed
23
- end
24
-
25
- def capturing?
26
- (_execution_state[CAPTURING_KEY] || 0) > 0
27
- end
28
-
29
- def begin_capture!
30
- _execution_state[CAPTURING_KEY] = (_execution_state[CAPTURING_KEY] || 0) + 1
31
- end
32
-
33
- def end_capture!
34
- depth = (_execution_state[CAPTURING_KEY] || 0) - 1
35
- _execution_state[CAPTURING_KEY] = [depth, 0].max
36
- end
37
-
38
- def published_events
39
- _execution_state[PUBLISHED_KEY] ||= []
40
- end
41
-
42
- def clear_published!
43
- _execution_state[PUBLISHED_KEY] = []
44
- end
45
- end
46
-
47
- module BusInterceptor
48
- def publish(event, sync:)
49
- if Dex::Event::EventTestWrapper.capturing?
50
- return if Dex::Event::Suppression.suppressed?(event.class)
51
-
52
- Dex::Event::EventTestWrapper.published_events << event
53
- else
54
- super(event, sync: true)
55
- end
56
- end
57
- end
58
- end
59
-
60
- module TestHelpers
61
- def self.included(base)
62
- EventTestWrapper.install!
63
- end
64
-
65
- def setup
66
- super
67
- EventTestWrapper.clear_published!
68
- Dex::Event::Trace.clear!
69
- Dex::Event::Suppression.clear!
70
- end
71
-
72
- def capture_events
73
- EventTestWrapper.begin_capture!
74
- yield
75
- ensure
76
- EventTestWrapper.end_capture!
77
- end
78
-
79
- private
80
-
81
- def _dex_published_events
82
- EventTestWrapper.published_events
83
- end
84
- end
85
- end
86
- end
87
-
88
- require_relative "event_test_helpers/assertions"
3
+ require_relative "event/test_helpers"
@@ -50,8 +50,12 @@ module Dex
50
50
  end
51
51
 
52
52
  def _build_query(model_class, column, value)
53
- if options[:case_sensitive] == false && value.is_a?(String) && model_class.respond_to?(:arel_table)
53
+ return model_class.where(column => value) unless options[:case_sensitive] == false && value.is_a?(String)
54
+
55
+ if model_class.respond_to?(:arel_table)
54
56
  model_class.where(model_class.arel_table[column].lower.eq(value.downcase))
57
+ elsif _mongoid_model_class?(model_class)
58
+ model_class.where(column => /\A#{Regexp.escape(value)}\z/i)
55
59
  else
56
60
  model_class.where(column => value)
57
61
  end
@@ -78,9 +82,21 @@ module Dex
78
82
  def _exclude_current_record(query, form)
79
83
  return query unless form.record&.persisted?
80
84
 
85
+ if _mongoid_record?(form.record)
86
+ return query.where(:_id.ne => form.record.id)
87
+ end
88
+
81
89
  pk = form.record.class.primary_key
82
90
  query.where.not(pk => form.record.public_send(pk))
83
91
  end
92
+
93
+ def _mongoid_model_class?(model_class)
94
+ defined?(Mongoid::Document) && model_class.include?(Mongoid::Document)
95
+ end
96
+
97
+ def _mongoid_record?(record)
98
+ _mongoid_model_class?(record.class)
99
+ end
84
100
  end
85
101
  end
86
102
  end
@@ -27,6 +27,7 @@ module Dex
27
27
  end
28
28
 
29
29
  def enqueue_record_job
30
+ @operation.send(:_record_validate_backend!, async: true)
30
31
  record = Dex.record_backend.create_record(
31
32
  name: operation_class_name,
32
33
  params: serialized_params,
@@ -45,6 +45,7 @@ module Dex
45
45
 
46
46
  def _explain_callable?(info)
47
47
  return false unless info[:guards][:passed]
48
+ return false if info[:record][:enabled] && info[:record][:status] == :misconfigured
48
49
 
49
50
  if info[:once][:active]
50
51
  return false if ONCE_BLOCKING_STATUSES.include?(info[:once][:status])
@@ -119,12 +120,7 @@ module Dex
119
120
  return :misconfigured if name.nil?
120
121
  return :misconfigured unless pipeline.steps.any? { |s| s.name == :record }
121
122
  return :unavailable unless Dex.record_backend
122
- return :misconfigured unless Dex.record_backend.has_field?("once_key")
123
-
124
- settings = settings_for(:once)
125
- if settings[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
126
- return :misconfigured
127
- end
123
+ return :misconfigured unless Dex.record_backend.missing_fields(send(:_once_required_fields)).empty?
128
124
 
129
125
  existing = Dex.record_backend.find_by_once_key(key)
130
126
  return :exists if existing
@@ -171,7 +167,15 @@ module Dex
171
167
  enabled: true,
172
168
  params: settings.fetch(:params, true),
173
169
  result: settings.fetch(:result, true)
174
- }
170
+ }.tap do |entry|
171
+ missing = Dex.record_backend.missing_fields(send(:_record_required_fields))
172
+ if missing.empty?
173
+ entry[:status] = :ready
174
+ else
175
+ entry[:status] = :misconfigured
176
+ entry[:missing_fields] = missing
177
+ end
178
+ end
175
179
  end
176
180
 
177
181
  def _explain_transaction
@@ -54,11 +54,24 @@ module Dex
54
54
  _lock_ensure_loaded!
55
55
  key = _lock_key
56
56
  ActiveRecord::Base.with_advisory_lock!(key, **_lock_options, &block)
57
- rescue WithAdvisoryLock::FailedToAcquireLock
58
- raise Dex::Error.new(:lock_timeout, "Could not acquire advisory lock: #{key}")
57
+ rescue => e
58
+ if defined?(WithAdvisoryLock::FailedToAcquireLock) && e.is_a?(WithAdvisoryLock::FailedToAcquireLock)
59
+ raise Dex::Error.new(:lock_timeout, "Could not acquire advisory lock: #{key}")
60
+ end
61
+
62
+ raise
59
63
  end
60
64
 
61
65
  def _lock_ensure_loaded!
66
+ unless defined?(ActiveRecord::Base)
67
+ raise LoadError, "advisory_lock requires ActiveRecord and is not supported in Mongoid-only apps."
68
+ end
69
+
70
+ unless defined?(WithAdvisoryLock::FailedToAcquireLock)
71
+ raise LoadError,
72
+ "with_advisory_lock gem is required for advisory locking. Add 'with_advisory_lock' to your Gemfile."
73
+ end
74
+
62
75
  return if ActiveRecord::Base.respond_to?(:with_advisory_lock!)
63
76
 
64
77
  raise LoadError,
@@ -44,6 +44,7 @@ module Dex
44
44
  else
45
45
  raise ArgumentError, "pass a String key or keyword arguments matching the once props"
46
46
  end
47
+ _once_validate_backend!
47
48
  Dex.record_backend.update_record_by_once_key(derived, once_key: nil)
48
49
  end
49
50
 
@@ -56,6 +57,27 @@ module Dex
56
57
 
57
58
  private
58
59
 
60
+ def _once_required_fields
61
+ fields =
62
+ if respond_to?(:_record_required_fields, true)
63
+ send(:_record_required_fields)
64
+ else
65
+ []
66
+ end
67
+
68
+ fields << "once_key"
69
+ fields << "once_key_expires_at" if settings_for(:once)[:expires_in]
70
+ fields.uniq
71
+ end
72
+
73
+ def _once_validate_backend!
74
+ unless Dex.record_backend
75
+ raise "once requires a record backend (configure Dex.record_class)"
76
+ end
77
+
78
+ Dex.record_backend.ensure_fields!(_once_required_fields, feature: "once")
79
+ end
80
+
59
81
  def _once_validate_props!(prop_names)
60
82
  return unless respond_to?(:literal_properties)
61
83
 
@@ -112,21 +134,7 @@ module Dex
112
134
  end
113
135
 
114
136
  def _once_ensure_backend!
115
- unless Dex.record_backend
116
- raise "once requires a record backend (configure Dex.record_class)"
117
- end
118
-
119
- return if self.class.instance_variable_defined?(:@_once_fields_checked)
120
-
121
- unless Dex.record_backend.has_field?("once_key")
122
- raise "once requires once_key column on #{Dex.record_class}. Run the migration to add it."
123
- end
124
-
125
- if self.class.settings_for(:once)[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
126
- raise "once with expires_in requires once_key_expires_at column on #{Dex.record_class}. Run the migration to add it."
127
- end
128
-
129
- self.class.instance_variable_set(:@_once_fields_checked, true)
137
+ self.class.send(:_once_validate_backend!)
130
138
  end
131
139
 
132
140
  def _once_derive_key
@@ -58,6 +58,19 @@ module Dex
58
58
  attributes.select { |key, _| has_field?(key.to_s) }
59
59
  end
60
60
 
61
+ def missing_fields(*fields)
62
+ fields.flatten.uniq.reject { |field_name| has_field?(field_name) }
63
+ end
64
+
65
+ def ensure_fields!(*fields, feature:)
66
+ missing = missing_fields(*fields)
67
+ return if missing.empty?
68
+
69
+ raise ArgumentError,
70
+ "Dex record_class #{record_class} is missing required attributes for #{feature}: #{missing.join(", ")}. " \
71
+ "Define these attributes on #{record_class} or disable #{feature}."
72
+ end
73
+
61
74
  def has_field?(field_name)
62
75
  raise NotImplementedError
63
76
  end
@@ -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
@@ -142,8 +158,8 @@ module Dex
142
158
  else
143
159
  case result
144
160
  when nil then nil
145
- when Hash then result
146
- else { _dex_value: result } # namespaced key so replay can distinguish wrapped primitives from user hashes
161
+ when Hash then _record_sanitize_value(result)
162
+ else { "_dex_value" => _record_sanitize_value(result) } # namespaced key so replay can distinguish wrapped primitives from user hashes
147
163
  end
148
164
  end
149
165
  end
@@ -160,10 +176,19 @@ module Dex
160
176
  case value
161
177
  when NilClass, String, Integer, Float, TrueClass, FalseClass then value
162
178
  when Symbol then value.to_s
163
- when Hash then value.transform_values { |v| _record_sanitize_value(v) }
179
+ when Hash
180
+ value.each_with_object({}) do |(key, nested_value), result|
181
+ result[key.to_s] = _record_sanitize_value(nested_value)
182
+ end
164
183
  when Array then value.map { |v| _record_sanitize_value(v) }
165
184
  when Exception then "#{value.class}: #{value.message}"
166
- else value.to_s
185
+ else
186
+ if value.respond_to?(:as_json)
187
+ serialized = value.as_json
188
+ return _record_sanitize_value(serialized) unless serialized.equal?(value)
189
+ end
190
+
191
+ value.to_s
167
192
  end
168
193
  end
169
194
 
@@ -0,0 +1,335 @@
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
+ # --- Transaction assertions ---
203
+
204
+ def assert_rolls_back(model_class, &block)
205
+ count_before = model_class.count
206
+ assert_raises(Dex::Error) { yield }
207
+ assert_equal count_before, model_class.count,
208
+ "Expected transaction to roll back, but #{model_class.name} count changed from #{count_before} to #{model_class.count}"
209
+ end
210
+
211
+ def assert_commits(model_class, &block)
212
+ count_before = model_class.count
213
+ yield
214
+ assert count_before < model_class.count,
215
+ "Expected #{model_class.name} count to increase, but it stayed at #{count_before}"
216
+ end
217
+
218
+ # --- Guard assertions ---
219
+
220
+ def assert_callable(*args, **params)
221
+ klass = _dex_resolve_subject(args)
222
+ result = klass.callable(**params)
223
+ assert result.ok?, "Expected operation to be callable, but guards failed:\n#{_dex_format_err(result)}"
224
+ result
225
+ end
226
+
227
+ def refute_callable(*args, **params)
228
+ klass_args, codes = _dex_split_class_and_symbols(args)
229
+ klass = _dex_resolve_subject(klass_args)
230
+ code = codes.first
231
+ result = klass.callable(**params)
232
+ refute result.ok?, "Expected operation to NOT be callable, but all guards passed"
233
+ if code
234
+ failed_codes = result.details.map { |f| f[:guard] }
235
+ assert_includes failed_codes, code,
236
+ "Expected guard :#{code} to fail, but it didn't.\n Failed guards: #{failed_codes.inspect}"
237
+ end
238
+ result
239
+ end
240
+
241
+ # --- Batch assertions ---
242
+
243
+ def assert_all_succeed(*args, params_list:)
244
+ klass = _dex_resolve_subject(args)
245
+ results = params_list.map { |p| klass.new(**p).safe.call }
246
+ failures = results.each_with_index.reject { |r, _| r.ok? }
247
+ if failures.any?
248
+ msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
249
+ flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
250
+ end
251
+ results
252
+ end
253
+
254
+ def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
255
+ klass = _dex_resolve_subject(args)
256
+ results = params_list.map { |p| klass.new(**p).safe.call }
257
+ failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
258
+ if failures.any?
259
+ msgs = failures.map { |r, i|
260
+ status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
261
+ " [#{i}] #{params_list[i].inspect} => #{status}"
262
+ }
263
+ flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
264
+ end
265
+ results.each_with_index do |r, i|
266
+ if message
267
+ case message
268
+ when Regexp
269
+ assert_match message, r.message,
270
+ "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
271
+ else
272
+ assert_equal message, r.message,
273
+ "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
274
+ end
275
+ end
276
+ details&.each do |key, val|
277
+ assert_equal val, r.details&.dig(key),
278
+ "Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
279
+ end
280
+ end
281
+ results
282
+ end
283
+
284
+ private
285
+
286
+ def _dex_format_err(result)
287
+ return "(not an error)" unless result.respond_to?(:error?) && result.error?
288
+
289
+ lines = [" code: #{result.code.inspect}"]
290
+ lines << " message: #{result.message.inspect}" if result.message && result.message != result.code.to_s
291
+ lines << " details: #{result.details.inspect}" if result.details
292
+ lines.join("\n")
293
+ end
294
+
295
+ def _dex_format_ok(result)
296
+ return "(not ok)" unless result.respond_to?(:ok?) && result.ok?
297
+
298
+ " value: #{result.value.inspect}"
299
+ end
300
+
301
+ def _dex_resolve_subject_and_code(args)
302
+ if args.first.is_a?(Class) && args.first < Dex::Operation
303
+ klass = args.shift
304
+ code = args.shift
305
+ [klass, code]
306
+ elsif args.first.is_a?(Symbol)
307
+ [_dex_resolve_subject([]), args.shift]
308
+ else
309
+ [_dex_resolve_subject([]), nil]
310
+ end
311
+ end
312
+
313
+ def _dex_split_class_and_symbols(args)
314
+ if args.first.is_a?(Class) && args.first < Dex::Operation
315
+ [args[0..0], args[1..]]
316
+ else
317
+ [[], args]
318
+ end
319
+ end
320
+
321
+ def _dex_split_class_and_hash(args)
322
+ hash = args.pop
323
+ klass_args = args.select { |a| a.is_a?(Class) && a < Dex::Operation }
324
+ [klass_args, hash]
325
+ end
326
+
327
+ def _dex_ensure_active_job_test_helper!
328
+ return if respond_to?(:assert_enqueued_with)
329
+
330
+ raise "assert_enqueues_operation requires ActiveJob::TestHelper. " \
331
+ "Include it in your test class: `include ActiveJob::TestHelper`"
332
+ end
333
+ end
334
+ end
335
+ end