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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +6 -2
- data/gemfiles/mongoid_no_ar.gemfile +10 -0
- data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
- data/guides/llm/EVENT.md +17 -4
- data/guides/llm/FORM.md +2 -2
- data/guides/llm/OPERATION.md +22 -17
- data/guides/llm/QUERY.md +2 -2
- data/lib/dex/event/bus.rb +7 -0
- data/lib/dex/event/test_helpers.rb +88 -0
- data/lib/dex/event_test_helpers.rb +1 -86
- data/lib/dex/form/uniqueness_validator.rb +17 -1
- data/lib/dex/operation/async_proxy.rb +1 -0
- data/lib/dex/operation/explain.rb +11 -7
- data/lib/dex/operation/lock_wrapper.rb +15 -2
- data/lib/dex/operation/once_wrapper.rb +23 -15
- data/lib/dex/operation/record_backend.rb +13 -0
- data/lib/dex/operation/record_wrapper.rb +29 -4
- data/lib/dex/operation/test_helpers/assertions.rb +335 -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 +150 -0
- data/lib/dex/operation/transaction_adapter.rb +29 -68
- data/lib/dex/operation/transaction_wrapper.rb +10 -16
- data/lib/dex/query/backend.rb +13 -0
- data/lib/dex/query.rb +9 -5
- data/lib/dex/ref_type.rb +4 -0
- data/lib/dex/test_helpers.rb +4 -139
- data/lib/dex/type_coercion.rb +4 -1
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +6 -5
- metadata +9 -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
|
@@ -1,88 +1,3 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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.
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|