devcycle-ruby-server-sdk 3.6.1 → 3.6.2
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/lib/devcycle-ruby-server-sdk/api/client.rb +86 -26
- data/lib/devcycle-ruby-server-sdk/api/dev_cycle_provider.rb +32 -5
- data/lib/devcycle-ruby-server-sdk/eval_hooks_runner.rb +135 -0
- data/lib/devcycle-ruby-server-sdk/models/eval_hook.rb +28 -0
- data/lib/devcycle-ruby-server-sdk/models/eval_hook_context.rb +22 -0
- data/lib/devcycle-ruby-server-sdk/version.rb +1 -1
- data/lib/devcycle-ruby-server-sdk.rb +5 -0
- data/spec/devcycle_provider_spec.rb +157 -7
- data/spec/eval_hooks_runner_spec.rb +410 -0
- data/spec/eval_hooks_spec.rb +245 -0
- metadata +9 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 914b4bf9e916b2796dcd6ce88be3ecf784b074fd30784bf8f28e2156b0bd8c66
|
4
|
+
data.tar.gz: dc4a335584b6618b4664203460530bc4421232dfdcc6362399694d7278f34bee
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6ca7f4b518dfa6bbd6ffa817263df4f625e7583a2ea61ad6b6ead5cd10ce12f2f586e205e457c39dc9d86831aadfd8e1ce7f1346c02362b1427bce7768a9fac0
|
7
|
+
data.tar.gz: 2d35f58deaa7a1309d8cb1aa6c5e7002e96e957f920d64d49d14a11e03a108051c679fc521acb451c1cd90a5dc32e9caa1754c92f308efecf592cd87da8cb78b
|
@@ -14,6 +14,7 @@ module DevCycle
|
|
14
14
|
@sdkKey = sdkKey
|
15
15
|
@dvc_options = dvc_options
|
16
16
|
@logger = dvc_options.logger
|
17
|
+
@eval_hooks_runner = EvalHooksRunner.new
|
17
18
|
|
18
19
|
if @dvc_options.enable_cloud_bucketing
|
19
20
|
@api_client = ApiClient.default
|
@@ -172,35 +173,68 @@ module DevCycle
|
|
172
173
|
|
173
174
|
validate_model(user)
|
174
175
|
|
175
|
-
|
176
|
-
|
177
|
-
|
176
|
+
# Create hook context
|
177
|
+
hook_context = HookContext.new(key: key, user: user, default_value: default)
|
178
|
+
|
179
|
+
before_hook_error = nil
|
180
|
+
# Run before hooks
|
181
|
+
begin
|
182
|
+
hook_context = @eval_hooks_runner.run_before_hooks(hook_context)
|
183
|
+
rescue BeforeHookError => e
|
184
|
+
before_hook_error = e
|
185
|
+
@logger.warn("Error in before hooks: #{e.message}")
|
178
186
|
end
|
179
187
|
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
value =
|
188
|
-
|
188
|
+
variable_result = nil
|
189
|
+
|
190
|
+
begin
|
191
|
+
if @dvc_options.enable_cloud_bucketing
|
192
|
+
data, _status_code, _headers = variable_with_http_info(key, user, default, opts)
|
193
|
+
variable_result = data
|
194
|
+
else
|
195
|
+
value = default
|
196
|
+
type = determine_variable_type(default)
|
197
|
+
defaulted = true
|
198
|
+
if local_bucketing_initialized? && @local_bucketing.has_config
|
199
|
+
type_code = variable_type_code_from_type(type)
|
200
|
+
variable_pb = variable_for_user_pb(user, key, type_code)
|
201
|
+
unless variable_pb.nil?
|
202
|
+
value = get_variable_value(variable_pb)
|
203
|
+
defaulted = false
|
204
|
+
end
|
205
|
+
else
|
206
|
+
@logger.warn("Local bucketing not initialized, returning default value for variable #{key}")
|
207
|
+
variable_event = Event.new({ type: DevCycle::EventTypes[:agg_variable_defaulted], target: key })
|
208
|
+
bucketed_config = BucketedUserConfig.new({}, {}, {}, {}, {}, {}, [])
|
209
|
+
@event_queue.queue_aggregate_event(variable_event, bucketed_config)
|
210
|
+
end
|
211
|
+
|
212
|
+
variable_result = Variable.new({
|
213
|
+
key: key,
|
214
|
+
value: value,
|
215
|
+
type: type,
|
216
|
+
defaultValue: default,
|
217
|
+
isDefaulted: defaulted
|
218
|
+
})
|
189
219
|
end
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
220
|
+
|
221
|
+
|
222
|
+
# Run after hooks only if no before hook error occurred
|
223
|
+
if before_hook_error != nil
|
224
|
+
@logger.info("before_hook_error is not nil, skipping after hooks")
|
225
|
+
raise before_hook_error
|
226
|
+
else
|
227
|
+
@eval_hooks_runner.run_after_hooks(hook_context)
|
228
|
+
end
|
229
|
+
rescue => e
|
230
|
+
# Run error hooks
|
231
|
+
@eval_hooks_runner.run_error_hooks(hook_context, e)
|
232
|
+
ensure
|
233
|
+
# Run finally hooks in all cases
|
234
|
+
@eval_hooks_runner.run_finally_hooks(hook_context)
|
235
|
+
end
|
236
|
+
|
237
|
+
variable_result
|
204
238
|
end
|
205
239
|
|
206
240
|
def variable_for_user(user, key, variable_type_code)
|
@@ -526,6 +560,32 @@ module DevCycle
|
|
526
560
|
raise ArgumentError.new("Invalid type code for variable: #{type_code}")
|
527
561
|
end
|
528
562
|
end
|
563
|
+
|
564
|
+
def get_variable_value(variable_pb)
|
565
|
+
case variable_pb.type
|
566
|
+
when :Boolean
|
567
|
+
variable_pb.boolValue
|
568
|
+
when :Number
|
569
|
+
variable_pb.doubleValue
|
570
|
+
when :String
|
571
|
+
variable_pb.stringValue
|
572
|
+
when :JSON
|
573
|
+
JSON.parse variable_pb.stringValue
|
574
|
+
end
|
575
|
+
end
|
576
|
+
|
577
|
+
# Adds an eval hook to the client
|
578
|
+
# @param [EvalHook] eval_hook The eval hook to add
|
579
|
+
# @return [void]
|
580
|
+
def add_eval_hook(eval_hook)
|
581
|
+
@eval_hooks_runner.add_hook(eval_hook)
|
582
|
+
end
|
583
|
+
|
584
|
+
# Clears all eval hooks from the client
|
585
|
+
# @return [void]
|
586
|
+
def clear_eval_hooks
|
587
|
+
@eval_hooks_runner.clear_hooks
|
588
|
+
end
|
529
589
|
end
|
530
590
|
|
531
591
|
# @deprecated Use `DevCycle::Client` instead.
|
@@ -48,15 +48,42 @@ module DevCycle
|
|
48
48
|
raise ArgumentError, "Invalid context type, expected OpenFeature::SDK::EvaluationContext but got #{context.class}"
|
49
49
|
end
|
50
50
|
args = {}
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
51
|
+
user_id = nil
|
52
|
+
user_id_field = nil
|
53
|
+
|
54
|
+
# Priority order: targeting_key -> user_id -> userId
|
55
|
+
if context.field('targeting_key')
|
56
|
+
user_id = context.field('targeting_key')
|
57
|
+
user_id_field = 'targeting_key'
|
58
|
+
elsif context.field('user_id')
|
59
|
+
user_id = context.field('user_id')
|
60
|
+
user_id_field = 'user_id'
|
61
|
+
elsif context.field('userId')
|
62
|
+
user_id = context.field('userId')
|
63
|
+
user_id_field = 'userId'
|
55
64
|
end
|
65
|
+
|
66
|
+
# Validate user_id is present and is a string
|
67
|
+
if user_id.nil?
|
68
|
+
raise ArgumentError, "User ID is required. Must provide one of: targeting_key, user_id, or userId"
|
69
|
+
end
|
70
|
+
|
71
|
+
unless user_id.is_a?(String)
|
72
|
+
raise ArgumentError, "User ID field '#{user_id_field}' must be a string, got #{user_id.class}"
|
73
|
+
end
|
74
|
+
|
75
|
+
# Check after type validation to avoid NoMethodError on non-strings
|
76
|
+
if user_id.empty?
|
77
|
+
raise ArgumentError, "User ID is required. Must provide one of: targeting_key, user_id, or userId"
|
78
|
+
end
|
79
|
+
|
80
|
+
args.merge!(user_id: user_id)
|
81
|
+
|
56
82
|
customData = {}
|
57
83
|
privateCustomData = {}
|
58
84
|
context.fields.each do |field, value|
|
59
|
-
|
85
|
+
# Skip all user ID fields from custom data
|
86
|
+
if field === 'targeting_key' || field === 'user_id' || field === 'userId'
|
60
87
|
next
|
61
88
|
end
|
62
89
|
if !(field === 'privateCustomData' || field === 'customData') && value.is_a?(Hash)
|
@@ -0,0 +1,135 @@
|
|
1
|
+
require 'devcycle-ruby-server-sdk/models/eval_hook'
|
2
|
+
require 'devcycle-ruby-server-sdk/models/eval_hook_context'
|
3
|
+
|
4
|
+
module DevCycle
|
5
|
+
# Custom error raised when a before hook fails
|
6
|
+
class BeforeHookError < StandardError
|
7
|
+
attr_reader :original_error, :hook_context
|
8
|
+
|
9
|
+
def initialize(message = nil, original_error = nil, hook_context = nil)
|
10
|
+
super(message || "Before hook execution failed")
|
11
|
+
@original_error = original_error
|
12
|
+
@hook_context = hook_context
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_s
|
16
|
+
msg = super
|
17
|
+
msg += "\nOriginal error: #{@original_error.message}" if @original_error
|
18
|
+
msg
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Custom error raised when an after hook fails
|
23
|
+
class AfterHookError < StandardError
|
24
|
+
attr_reader :original_error, :hook_context
|
25
|
+
|
26
|
+
def initialize(message = nil, original_error = nil, hook_context = nil)
|
27
|
+
super(message || "After hook execution failed")
|
28
|
+
@original_error = original_error
|
29
|
+
@hook_context = hook_context
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_s
|
33
|
+
msg = super
|
34
|
+
msg += "\nOriginal error: #{@original_error.message}" if @original_error
|
35
|
+
msg
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class EvalHooksRunner
|
40
|
+
# @return [Array<EvalHook>] Array of eval hooks to run
|
41
|
+
attr_reader :eval_hooks
|
42
|
+
|
43
|
+
# Initializes the EvalHooksRunner with an optional array of eval hooks
|
44
|
+
# @param [Array<EvalHook>, nil] eval_hooks Array of eval hooks to run
|
45
|
+
def initialize(eval_hooks = [])
|
46
|
+
@eval_hooks = eval_hooks || []
|
47
|
+
end
|
48
|
+
|
49
|
+
# Runs all before hooks with the given context
|
50
|
+
# @param [HookContext] context The context to pass to the hooks
|
51
|
+
# @return [HookContext] The potentially modified context
|
52
|
+
# @raise [BeforeHookError] when a before hook fails
|
53
|
+
def run_before_hooks(context)
|
54
|
+
current_context = context
|
55
|
+
|
56
|
+
@eval_hooks.each do |hook|
|
57
|
+
next unless hook.before
|
58
|
+
|
59
|
+
begin
|
60
|
+
result = hook.before.call(current_context)
|
61
|
+
# If the hook returns a new context, use it for subsequent hooks
|
62
|
+
current_context = result if result.is_a?(DevCycle::HookContext)
|
63
|
+
rescue => e
|
64
|
+
# Raise BeforeHookError to allow client to handle and skip after hooks
|
65
|
+
raise BeforeHookError.new(e.message, e, current_context)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
current_context
|
70
|
+
end
|
71
|
+
|
72
|
+
# Runs all after hooks with the given context
|
73
|
+
# @param [HookContext] context The context to pass to the hooks
|
74
|
+
# @return [void]
|
75
|
+
# @raise [AfterHookError] when an after hook fails
|
76
|
+
def run_after_hooks(context)
|
77
|
+
@eval_hooks.each do |hook|
|
78
|
+
next unless hook.after
|
79
|
+
|
80
|
+
begin
|
81
|
+
hook.after.call(context)
|
82
|
+
rescue => e
|
83
|
+
# Log error but continue with next hook
|
84
|
+
raise AfterHookError.new(e.message, e, context)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Runs all finally hooks with the given context
|
90
|
+
# @param [HookContext] context The context to pass to the hooks
|
91
|
+
# @return [void]
|
92
|
+
def run_finally_hooks(context)
|
93
|
+
@eval_hooks.each do |hook|
|
94
|
+
next unless hook.on_finally
|
95
|
+
|
96
|
+
begin
|
97
|
+
hook.on_finally.call(context)
|
98
|
+
rescue => e
|
99
|
+
# Log error but don't re-raise to prevent blocking evaluation
|
100
|
+
warn "Error in finally hook: #{e.message}"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Runs all error hooks with the given context and error
|
106
|
+
# @param [HookContext] context The context to pass to the hooks
|
107
|
+
# @param [Exception] error The error that occurred
|
108
|
+
# @return [void]
|
109
|
+
def run_error_hooks(context, error)
|
110
|
+
@eval_hooks.each do |hook|
|
111
|
+
next unless hook.error
|
112
|
+
|
113
|
+
begin
|
114
|
+
hook.error.call(context, error)
|
115
|
+
rescue => e
|
116
|
+
# Log error but don't re-raise to prevent blocking evaluation
|
117
|
+
warn "Error in error hook: #{e.message}"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Adds an eval hook to the runner
|
123
|
+
# @param [EvalHook] eval_hook The eval hook to add
|
124
|
+
# @return [void]
|
125
|
+
def add_hook(eval_hook)
|
126
|
+
@eval_hooks << eval_hook
|
127
|
+
end
|
128
|
+
|
129
|
+
# Clears all eval hooks from the runner
|
130
|
+
# @return [void]
|
131
|
+
def clear_hooks
|
132
|
+
@eval_hooks.clear
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module DevCycle
|
2
|
+
class EvalHook
|
3
|
+
# Callback to be executed before evaluation
|
4
|
+
attr_accessor :before
|
5
|
+
|
6
|
+
# Callback to be executed after evaluation
|
7
|
+
attr_accessor :after
|
8
|
+
|
9
|
+
# Callback to be executed finally (always runs)
|
10
|
+
attr_accessor :on_finally
|
11
|
+
|
12
|
+
# Callback to be executed on error
|
13
|
+
attr_accessor :error
|
14
|
+
|
15
|
+
# Initializes the object with optional callback functions
|
16
|
+
# @param [Hash] callbacks Callback functions in the form of hash
|
17
|
+
# @option callbacks [Proc, nil] :before Callback to execute before evaluation
|
18
|
+
# @option callbacks [Proc, nil] :after Callback to execute after evaluation
|
19
|
+
# @option callbacks [Proc, nil] :on_finally Callback to execute finally (always runs)
|
20
|
+
# @option callbacks [Proc, nil] :error Callback to execute on error
|
21
|
+
def initialize(callbacks = {})
|
22
|
+
@before = callbacks[:before]
|
23
|
+
@after = callbacks[:after]
|
24
|
+
@on_finally = callbacks[:on_finally]
|
25
|
+
@error = callbacks[:error]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module DevCycle
|
2
|
+
class HookContext
|
3
|
+
# The key of the variable being evaluated
|
4
|
+
attr_accessor :key
|
5
|
+
|
6
|
+
# The user for whom the variable is being evaluated
|
7
|
+
attr_accessor :user
|
8
|
+
|
9
|
+
# The default value for the variable
|
10
|
+
attr_accessor :default_value
|
11
|
+
|
12
|
+
# Initializes the object
|
13
|
+
# @param [String] key The key of the variable being evaluated
|
14
|
+
# @param [DevCycle::User] user The user for whom the variable is being evaluated
|
15
|
+
# @param [Object] default_value The default value for the variable
|
16
|
+
def initialize(key:, user:, default_value:)
|
17
|
+
@key = key
|
18
|
+
@user = user
|
19
|
+
@default_value = default_value
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -25,6 +25,11 @@ require 'devcycle-ruby-server-sdk/models/user'
|
|
25
25
|
require 'devcycle-ruby-server-sdk/models/user_data_and_events_body'
|
26
26
|
require 'devcycle-ruby-server-sdk/models/variable'
|
27
27
|
|
28
|
+
# Eval Hooks
|
29
|
+
require 'devcycle-ruby-server-sdk/eval_hooks_runner'
|
30
|
+
require 'devcycle-ruby-server-sdk/models/eval_hook'
|
31
|
+
require 'devcycle-ruby-server-sdk/models/eval_hook_context'
|
32
|
+
|
28
33
|
# APIs
|
29
34
|
require 'devcycle-ruby-server-sdk/api/client'
|
30
35
|
require 'devcycle-ruby-server-sdk/api/dev_cycle_provider'
|
@@ -4,18 +4,109 @@ require 'spec_helper'
|
|
4
4
|
require 'open_feature/sdk'
|
5
5
|
|
6
6
|
context 'user_from_openfeature_context' do
|
7
|
-
context 'user_id' do
|
7
|
+
context 'user_id validation' do
|
8
|
+
it 'raises error when no user ID fields are provided' do
|
9
|
+
context = OpenFeature::SDK::EvaluationContext.new(email: 'test@example.com')
|
10
|
+
expect {
|
11
|
+
DevCycle::Provider.user_from_openfeature_context(context)
|
12
|
+
}.to raise_error(ArgumentError, "User ID is required. Must provide one of: targeting_key, user_id, or userId")
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'raises error when targeting_key is not a string' do
|
16
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 123)
|
17
|
+
expect {
|
18
|
+
DevCycle::Provider.user_from_openfeature_context(context)
|
19
|
+
}.to raise_error(ArgumentError, "User ID field 'targeting_key' must be a string, got Integer")
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'raises error when user_id is not a string' do
|
23
|
+
context = OpenFeature::SDK::EvaluationContext.new(user_id: 123)
|
24
|
+
expect {
|
25
|
+
DevCycle::Provider.user_from_openfeature_context(context)
|
26
|
+
}.to raise_error(ArgumentError, "User ID field 'user_id' must be a string, got Integer")
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'raises error when userId is not a string' do
|
30
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: 123)
|
31
|
+
expect {
|
32
|
+
DevCycle::Provider.user_from_openfeature_context(context)
|
33
|
+
}.to raise_error(ArgumentError, "User ID field 'userId' must be a string, got Integer")
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'raises error when targeting_key is nil' do
|
37
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: nil)
|
38
|
+
expect {
|
39
|
+
DevCycle::Provider.user_from_openfeature_context(context)
|
40
|
+
}.to raise_error(ArgumentError, "User ID is required. Must provide one of: targeting_key, user_id, or userId")
|
41
|
+
end
|
8
42
|
|
9
|
-
it '
|
10
|
-
context = OpenFeature::SDK::EvaluationContext.new(
|
43
|
+
it 'raises error when targeting_key is empty string' do
|
44
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: '')
|
45
|
+
expect {
|
46
|
+
DevCycle::Provider.user_from_openfeature_context(context)
|
47
|
+
}.to raise_error(ArgumentError, "User ID is required. Must provide one of: targeting_key, user_id, or userId")
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'raises error when user_id is empty string' do
|
51
|
+
context = OpenFeature::SDK::EvaluationContext.new(user_id: '')
|
52
|
+
expect {
|
53
|
+
DevCycle::Provider.user_from_openfeature_context(context)
|
54
|
+
}.to raise_error(ArgumentError, "User ID is required. Must provide one of: targeting_key, user_id, or userId")
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'raises error when userId is empty string' do
|
58
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: '')
|
59
|
+
expect {
|
60
|
+
DevCycle::Provider.user_from_openfeature_context(context)
|
61
|
+
}.to raise_error(ArgumentError, "User ID is required. Must provide one of: targeting_key, user_id, or userId")
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
context 'user_id fields priority' do
|
66
|
+
it 'returns a user with the user_id from the context when only user_id is provided' do
|
67
|
+
context = OpenFeature::SDK::EvaluationContext.new(user_id: 'user_id_value')
|
11
68
|
user = DevCycle::Provider.user_from_openfeature_context(context)
|
12
|
-
expect(user.user_id).to eq('
|
69
|
+
expect(user.user_id).to eq('user_id_value')
|
13
70
|
end
|
14
71
|
|
15
|
-
it 'returns a user with the targeting_key from the context' do
|
16
|
-
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: '
|
72
|
+
it 'returns a user with the targeting_key from the context when only targeting_key is provided' do
|
73
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 'targeting_key_value')
|
17
74
|
user = DevCycle::Provider.user_from_openfeature_context(context)
|
18
|
-
expect(user.user_id).to eq('
|
75
|
+
expect(user.user_id).to eq('targeting_key_value')
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'returns a user with the userId from the context when only userId is provided' do
|
79
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: 'userId_value')
|
80
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
81
|
+
expect(user.user_id).to eq('userId_value')
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'prioritizes targeting_key over user_id' do
|
85
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 'targeting_key_value', user_id: 'user_id_value')
|
86
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
87
|
+
expect(user.user_id).to eq('targeting_key_value')
|
88
|
+
expect(user.customData).to eq({})
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'prioritizes targeting_key over userId' do
|
92
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 'targeting_key_value', userId: 'userId_value')
|
93
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
94
|
+
expect(user.user_id).to eq('targeting_key_value')
|
95
|
+
expect(user.customData).to eq({})
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'prioritizes user_id over userId' do
|
99
|
+
context = OpenFeature::SDK::EvaluationContext.new(user_id: 'user_id_value', userId: 'userId_value')
|
100
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
101
|
+
expect(user.user_id).to eq('user_id_value')
|
102
|
+
expect(user.customData).to eq({})
|
103
|
+
end
|
104
|
+
|
105
|
+
it 'prioritizes targeting_key over both user_id and userId' do
|
106
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 'targeting_key_value', user_id: 'user_id_value', userId: 'userId_value')
|
107
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
108
|
+
expect(user.user_id).to eq('targeting_key_value')
|
109
|
+
expect(user.customData).to eq({})
|
19
110
|
end
|
20
111
|
end
|
21
112
|
context 'email' do
|
@@ -31,6 +122,19 @@ context 'user_from_openfeature_context' do
|
|
31
122
|
expect(user.user_id).to eq('targeting_key')
|
32
123
|
expect(user.email).to eq('email')
|
33
124
|
end
|
125
|
+
it 'returns a user with a valid userId and email' do
|
126
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: 'userId', email: 'email')
|
127
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
128
|
+
expect(user.user_id).to eq('userId')
|
129
|
+
expect(user.email).to eq('email')
|
130
|
+
end
|
131
|
+
it 'prioritizes targeting_key over user_id with email' do
|
132
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 'targeting_key', user_id: 'user_id', email: 'email')
|
133
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
134
|
+
expect(user.user_id).to eq('targeting_key')
|
135
|
+
expect(user.email).to eq('email')
|
136
|
+
expect(user.customData).to eq({})
|
137
|
+
end
|
34
138
|
end
|
35
139
|
|
36
140
|
context 'customData' do
|
@@ -40,6 +144,18 @@ context 'user_from_openfeature_context' do
|
|
40
144
|
expect(user.user_id).to eq('user_id')
|
41
145
|
expect(user.customData).to eq({ 'key' => 'value' })
|
42
146
|
end
|
147
|
+
it 'returns a user with userId and customData' do
|
148
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: 'userId', customData: { 'key' => 'value' })
|
149
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
150
|
+
expect(user.user_id).to eq('userId')
|
151
|
+
expect(user.customData).to eq({ 'key' => 'value' })
|
152
|
+
end
|
153
|
+
it 'excludes all user ID fields from customData' do
|
154
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 'targeting_key', user_id: 'user_id', userId: 'userId', customData: { 'key' => 'value' })
|
155
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
156
|
+
expect(user.user_id).to eq('targeting_key')
|
157
|
+
expect(user.customData).to eq({ 'key' => 'value' })
|
158
|
+
end
|
43
159
|
end
|
44
160
|
|
45
161
|
context 'privateCustomData' do
|
@@ -49,6 +165,12 @@ context 'user_from_openfeature_context' do
|
|
49
165
|
expect(user.user_id).to eq('user_id')
|
50
166
|
expect(user.privateCustomData).to eq({ 'key' => 'value' })
|
51
167
|
end
|
168
|
+
it 'returns a user with userId and privateCustomData' do
|
169
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: 'userId', privateCustomData: { 'key' => 'value' })
|
170
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
171
|
+
expect(user.user_id).to eq('userId')
|
172
|
+
expect(user.privateCustomData).to eq({ 'key' => 'value' })
|
173
|
+
end
|
52
174
|
end
|
53
175
|
|
54
176
|
context 'appVersion' do
|
@@ -65,6 +187,20 @@ context 'user_from_openfeature_context' do
|
|
65
187
|
expect(user.user_id).to eq('user_id')
|
66
188
|
expect(user.appBuild).to eq(1)
|
67
189
|
end
|
190
|
+
|
191
|
+
it 'returns a user with userId and appVersion' do
|
192
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: 'userId', appVersion: '1.0.0')
|
193
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
194
|
+
expect(user.user_id).to eq('userId')
|
195
|
+
expect(user.appVersion).to eq('1.0.0')
|
196
|
+
end
|
197
|
+
|
198
|
+
it 'returns a user with userId and appBuild' do
|
199
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: 'userId', appBuild: 1)
|
200
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
201
|
+
expect(user.user_id).to eq('userId')
|
202
|
+
expect(user.appBuild).to eq(1)
|
203
|
+
end
|
68
204
|
end
|
69
205
|
context 'randomFields' do
|
70
206
|
it 'returns a user with customData fields mapped to any non-standard fields' do
|
@@ -73,6 +209,20 @@ context 'user_from_openfeature_context' do
|
|
73
209
|
expect(user.user_id).to eq('user_id')
|
74
210
|
expect(user.customData).to eq({ 'randomField' => 'value' })
|
75
211
|
end
|
212
|
+
|
213
|
+
it 'returns a user with userId and customData fields mapped to any non-standard fields' do
|
214
|
+
context = OpenFeature::SDK::EvaluationContext.new(userId: 'userId', randomField: 'value')
|
215
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
216
|
+
expect(user.user_id).to eq('userId')
|
217
|
+
expect(user.customData).to eq({ 'randomField' => 'value' })
|
218
|
+
end
|
219
|
+
|
220
|
+
it 'excludes all user ID fields from custom data with random fields' do
|
221
|
+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 'targeting_key', user_id: 'user_id', userId: 'userId', randomField: 'value')
|
222
|
+
user = DevCycle::Provider.user_from_openfeature_context(context)
|
223
|
+
expect(user.user_id).to eq('targeting_key')
|
224
|
+
expect(user.customData).to eq({ 'randomField' => 'value' })
|
225
|
+
end
|
76
226
|
end
|
77
227
|
|
78
228
|
context 'provider' do
|
@@ -0,0 +1,410 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'devcycle-ruby-server-sdk/eval_hooks_runner'
|
3
|
+
|
4
|
+
describe DevCycle::EvalHooksRunner do
|
5
|
+
let(:test_context) { DevCycle::HookContext.new(key: 'test-key', user: 'test-user', default_value: 'test-default') }
|
6
|
+
|
7
|
+
describe 'initialization' do
|
8
|
+
it 'initializes with empty hooks array' do
|
9
|
+
runner = DevCycle::EvalHooksRunner.new
|
10
|
+
expect(runner.eval_hooks).to be_empty
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'initializes with provided hooks' do
|
14
|
+
hook = DevCycle::EvalHook.new
|
15
|
+
runner = DevCycle::EvalHooksRunner.new([hook])
|
16
|
+
expect(runner.eval_hooks).to include(hook)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe '#add_hook' do
|
21
|
+
it 'adds a hook to the runner' do
|
22
|
+
runner = DevCycle::EvalHooksRunner.new
|
23
|
+
hook = DevCycle::EvalHook.new
|
24
|
+
|
25
|
+
runner.add_hook(hook)
|
26
|
+
expect(runner.eval_hooks).to include(hook)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'can add multiple hooks' do
|
30
|
+
runner = DevCycle::EvalHooksRunner.new
|
31
|
+
hook1 = DevCycle::EvalHook.new
|
32
|
+
hook2 = DevCycle::EvalHook.new
|
33
|
+
|
34
|
+
runner.add_hook(hook1)
|
35
|
+
runner.add_hook(hook2)
|
36
|
+
|
37
|
+
expect(runner.eval_hooks).to include(hook1, hook2)
|
38
|
+
expect(runner.eval_hooks.length).to eq(2)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe '#clear_hooks' do
|
43
|
+
it 'removes all hooks from the runner' do
|
44
|
+
runner = DevCycle::EvalHooksRunner.new
|
45
|
+
hook = DevCycle::EvalHook.new
|
46
|
+
|
47
|
+
runner.add_hook(hook)
|
48
|
+
expect(runner.eval_hooks).not_to be_empty
|
49
|
+
|
50
|
+
runner.clear_hooks
|
51
|
+
expect(runner.eval_hooks).to be_empty
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe '#run_before_hooks' do
|
56
|
+
it 'runs before hooks in order' do
|
57
|
+
execution_order = []
|
58
|
+
runner = DevCycle::EvalHooksRunner.new
|
59
|
+
|
60
|
+
hook1 = DevCycle::EvalHook.new(
|
61
|
+
before: ->(context) {
|
62
|
+
execution_order << 'hook1'
|
63
|
+
context
|
64
|
+
}
|
65
|
+
)
|
66
|
+
|
67
|
+
hook2 = DevCycle::EvalHook.new(
|
68
|
+
before: ->(context) {
|
69
|
+
execution_order << 'hook2'
|
70
|
+
context
|
71
|
+
}
|
72
|
+
)
|
73
|
+
|
74
|
+
runner.add_hook(hook1)
|
75
|
+
runner.add_hook(hook2)
|
76
|
+
|
77
|
+
result = runner.run_before_hooks(test_context)
|
78
|
+
|
79
|
+
expect(execution_order).to eq(['hook1', 'hook2'])
|
80
|
+
expect(result).to eq(test_context)
|
81
|
+
end
|
82
|
+
|
83
|
+
it 'returns modified context from before hook' do
|
84
|
+
runner = DevCycle::EvalHooksRunner.new
|
85
|
+
modified_context = DevCycle::HookContext.new(key: 'modified', user: 'modified-user', default_value: 'modified-default')
|
86
|
+
|
87
|
+
hook = DevCycle::EvalHook.new(
|
88
|
+
before: ->(context) {
|
89
|
+
modified_context
|
90
|
+
}
|
91
|
+
)
|
92
|
+
|
93
|
+
runner.add_hook(hook)
|
94
|
+
result = runner.run_before_hooks(test_context)
|
95
|
+
|
96
|
+
expect(result).to eq(modified_context)
|
97
|
+
end
|
98
|
+
|
99
|
+
it 'handles hooks without before callbacks' do
|
100
|
+
runner = DevCycle::EvalHooksRunner.new
|
101
|
+
hook = DevCycle::EvalHook.new # No before callback
|
102
|
+
|
103
|
+
runner.add_hook(hook)
|
104
|
+
result = runner.run_before_hooks(test_context)
|
105
|
+
|
106
|
+
expect(result).to eq(test_context)
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'raises BeforeHookError when a before hook raises an error' do
|
110
|
+
runner = DevCycle::EvalHooksRunner.new
|
111
|
+
hook1_called = false
|
112
|
+
|
113
|
+
hook1 = DevCycle::EvalHook.new(
|
114
|
+
before: ->(context) {
|
115
|
+
hook1_called = true
|
116
|
+
raise StandardError, 'Hook 1 error'
|
117
|
+
}
|
118
|
+
)
|
119
|
+
|
120
|
+
hook2 = DevCycle::EvalHook.new(
|
121
|
+
before: ->(context) {
|
122
|
+
# This should not be called because hook1 raises an error
|
123
|
+
context
|
124
|
+
}
|
125
|
+
)
|
126
|
+
|
127
|
+
runner.add_hook(hook1)
|
128
|
+
runner.add_hook(hook2)
|
129
|
+
|
130
|
+
expect { runner.run_before_hooks(test_context) }.to raise_error(DevCycle::BeforeHookError, /Hook 1 error/)
|
131
|
+
expect(hook1_called).to be true
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
describe '#run_after_hooks' do
|
136
|
+
it 'runs after hooks in order' do
|
137
|
+
execution_order = []
|
138
|
+
runner = DevCycle::EvalHooksRunner.new
|
139
|
+
|
140
|
+
hook1 = DevCycle::EvalHook.new(
|
141
|
+
after: ->(context) {
|
142
|
+
execution_order << 'hook1'
|
143
|
+
}
|
144
|
+
)
|
145
|
+
|
146
|
+
hook2 = DevCycle::EvalHook.new(
|
147
|
+
after: ->(context) {
|
148
|
+
execution_order << 'hook2'
|
149
|
+
}
|
150
|
+
)
|
151
|
+
|
152
|
+
runner.add_hook(hook1)
|
153
|
+
runner.add_hook(hook2)
|
154
|
+
|
155
|
+
runner.run_after_hooks(test_context)
|
156
|
+
|
157
|
+
expect(execution_order).to eq(['hook1', 'hook2'])
|
158
|
+
end
|
159
|
+
|
160
|
+
it 'handles hooks without after callbacks' do
|
161
|
+
runner = DevCycle::EvalHooksRunner.new
|
162
|
+
hook = DevCycle::EvalHook.new # No after callback
|
163
|
+
|
164
|
+
expect { runner.run_after_hooks(test_context) }.not_to raise_error
|
165
|
+
end
|
166
|
+
|
167
|
+
it 'raises AfterHookError when an after hook raises an error' do
|
168
|
+
runner = DevCycle::EvalHooksRunner.new
|
169
|
+
hook1_called = false
|
170
|
+
|
171
|
+
hook1 = DevCycle::EvalHook.new(
|
172
|
+
after: ->(context) {
|
173
|
+
hook1_called = true
|
174
|
+
raise StandardError, 'Hook 1 error'
|
175
|
+
}
|
176
|
+
)
|
177
|
+
|
178
|
+
hook2 = DevCycle::EvalHook.new(
|
179
|
+
after: ->(context) {
|
180
|
+
# This should not be called because hook1 raises an error
|
181
|
+
}
|
182
|
+
)
|
183
|
+
|
184
|
+
runner.add_hook(hook1)
|
185
|
+
runner.add_hook(hook2)
|
186
|
+
|
187
|
+
expect { runner.run_after_hooks(test_context) }.to raise_error(DevCycle::AfterHookError, /Hook 1 error/)
|
188
|
+
expect(hook1_called).to be true
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
describe '#run_error_hooks' do
|
193
|
+
it 'runs error hooks with context and error' do
|
194
|
+
runner = DevCycle::EvalHooksRunner.new
|
195
|
+
error_hook_called = false
|
196
|
+
received_error = nil
|
197
|
+
|
198
|
+
hook = DevCycle::EvalHook.new(
|
199
|
+
error: ->(context, error) {
|
200
|
+
error_hook_called = true
|
201
|
+
received_error = error
|
202
|
+
}
|
203
|
+
)
|
204
|
+
|
205
|
+
runner.add_hook(hook)
|
206
|
+
test_error = StandardError.new('Test error')
|
207
|
+
|
208
|
+
runner.run_error_hooks(test_context, test_error)
|
209
|
+
|
210
|
+
expect(error_hook_called).to be true
|
211
|
+
expect(received_error).to eq(test_error)
|
212
|
+
end
|
213
|
+
|
214
|
+
it 'runs multiple error hooks in order' do
|
215
|
+
execution_order = []
|
216
|
+
runner = DevCycle::EvalHooksRunner.new
|
217
|
+
|
218
|
+
hook1 = DevCycle::EvalHook.new(
|
219
|
+
error: ->(context, error) {
|
220
|
+
execution_order << 'hook1'
|
221
|
+
}
|
222
|
+
)
|
223
|
+
|
224
|
+
hook2 = DevCycle::EvalHook.new(
|
225
|
+
error: ->(context, error) {
|
226
|
+
execution_order << 'hook2'
|
227
|
+
}
|
228
|
+
)
|
229
|
+
|
230
|
+
runner.add_hook(hook1)
|
231
|
+
runner.add_hook(hook2)
|
232
|
+
|
233
|
+
test_error = StandardError.new('Test error')
|
234
|
+
runner.run_error_hooks(test_context, test_error)
|
235
|
+
|
236
|
+
expect(execution_order).to eq(['hook1', 'hook2'])
|
237
|
+
end
|
238
|
+
|
239
|
+
it 'handles hooks without error callbacks' do
|
240
|
+
runner = DevCycle::EvalHooksRunner.new
|
241
|
+
hook = DevCycle::EvalHook.new # No error callback
|
242
|
+
|
243
|
+
test_error = StandardError.new('Test error')
|
244
|
+
expect { runner.run_error_hooks(test_context, test_error) }.not_to raise_error
|
245
|
+
end
|
246
|
+
|
247
|
+
it 'continues execution when an error hook raises an error' do
|
248
|
+
runner = DevCycle::EvalHooksRunner.new
|
249
|
+
hook1_called = false
|
250
|
+
hook2_called = false
|
251
|
+
|
252
|
+
hook1 = DevCycle::EvalHook.new(
|
253
|
+
error: ->(context, error) {
|
254
|
+
hook1_called = true
|
255
|
+
raise StandardError, 'Error hook error'
|
256
|
+
}
|
257
|
+
)
|
258
|
+
|
259
|
+
hook2 = DevCycle::EvalHook.new(
|
260
|
+
error: ->(context, error) {
|
261
|
+
hook2_called = true
|
262
|
+
}
|
263
|
+
)
|
264
|
+
|
265
|
+
runner.add_hook(hook1)
|
266
|
+
runner.add_hook(hook2)
|
267
|
+
|
268
|
+
test_error = StandardError.new('Test error')
|
269
|
+
runner.run_error_hooks(test_context, test_error)
|
270
|
+
|
271
|
+
expect(hook1_called).to be true
|
272
|
+
expect(hook2_called).to be true
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
describe '#run_finally_hooks' do
|
277
|
+
it 'runs finally hooks in order' do
|
278
|
+
execution_order = []
|
279
|
+
runner = DevCycle::EvalHooksRunner.new
|
280
|
+
|
281
|
+
hook1 = DevCycle::EvalHook.new(
|
282
|
+
on_finally: ->(context) {
|
283
|
+
execution_order << 'hook1'
|
284
|
+
}
|
285
|
+
)
|
286
|
+
|
287
|
+
hook2 = DevCycle::EvalHook.new(
|
288
|
+
on_finally: ->(context) {
|
289
|
+
execution_order << 'hook2'
|
290
|
+
}
|
291
|
+
)
|
292
|
+
|
293
|
+
runner.add_hook(hook1)
|
294
|
+
runner.add_hook(hook2)
|
295
|
+
|
296
|
+
runner.run_finally_hooks(test_context)
|
297
|
+
|
298
|
+
expect(execution_order).to eq(['hook1', 'hook2'])
|
299
|
+
end
|
300
|
+
|
301
|
+
it 'handles hooks without finally callbacks' do
|
302
|
+
runner = DevCycle::EvalHooksRunner.new
|
303
|
+
hook = DevCycle::EvalHook.new # No finally callback
|
304
|
+
|
305
|
+
expect { runner.run_finally_hooks(test_context) }.not_to raise_error
|
306
|
+
end
|
307
|
+
|
308
|
+
it 'continues execution when a finally hook raises an error' do
|
309
|
+
runner = DevCycle::EvalHooksRunner.new
|
310
|
+
hook1_called = false
|
311
|
+
hook2_called = false
|
312
|
+
|
313
|
+
hook1 = DevCycle::EvalHook.new(
|
314
|
+
on_finally: ->(context) {
|
315
|
+
hook1_called = true
|
316
|
+
raise StandardError, 'Finally hook error'
|
317
|
+
}
|
318
|
+
)
|
319
|
+
|
320
|
+
hook2 = DevCycle::EvalHook.new(
|
321
|
+
on_finally: ->(context) {
|
322
|
+
hook2_called = true
|
323
|
+
}
|
324
|
+
)
|
325
|
+
|
326
|
+
runner.add_hook(hook1)
|
327
|
+
runner.add_hook(hook2)
|
328
|
+
|
329
|
+
runner.run_finally_hooks(test_context)
|
330
|
+
|
331
|
+
expect(hook1_called).to be true
|
332
|
+
expect(hook2_called).to be true
|
333
|
+
end
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
describe DevCycle::EvalHook do
|
338
|
+
describe 'initialization' do
|
339
|
+
it 'initializes with no callbacks' do
|
340
|
+
hook = DevCycle::EvalHook.new
|
341
|
+
expect(hook.before).to be_nil
|
342
|
+
expect(hook.after).to be_nil
|
343
|
+
expect(hook.on_finally).to be_nil
|
344
|
+
expect(hook.error).to be_nil
|
345
|
+
end
|
346
|
+
|
347
|
+
it 'initializes with provided callbacks' do
|
348
|
+
before_callback = ->(context) { context }
|
349
|
+
after_callback = ->(context) { }
|
350
|
+
error_callback = ->(context, error) { }
|
351
|
+
finally_callback = ->(context) { }
|
352
|
+
|
353
|
+
hook = DevCycle::EvalHook.new(
|
354
|
+
before: before_callback,
|
355
|
+
after: after_callback,
|
356
|
+
error: error_callback,
|
357
|
+
on_finally: finally_callback
|
358
|
+
)
|
359
|
+
|
360
|
+
expect(hook.before).to eq(before_callback)
|
361
|
+
expect(hook.after).to eq(after_callback)
|
362
|
+
expect(hook.error).to eq(error_callback)
|
363
|
+
expect(hook.on_finally).to eq(finally_callback)
|
364
|
+
end
|
365
|
+
|
366
|
+
it 'initializes with partial callbacks' do
|
367
|
+
before_callback = ->(context) { context }
|
368
|
+
|
369
|
+
hook = DevCycle::EvalHook.new(before: before_callback)
|
370
|
+
|
371
|
+
expect(hook.before).to eq(before_callback)
|
372
|
+
expect(hook.after).to be_nil
|
373
|
+
expect(hook.on_finally).to be_nil
|
374
|
+
expect(hook.error).to be_nil
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
describe DevCycle::HookContext do
|
380
|
+
describe 'initialization' do
|
381
|
+
it 'initializes with required parameters' do
|
382
|
+
user = { user_id: 'test-user' }
|
383
|
+
context = DevCycle::HookContext.new(
|
384
|
+
key: 'test-key',
|
385
|
+
user: user,
|
386
|
+
default_value: 'test-default'
|
387
|
+
)
|
388
|
+
|
389
|
+
expect(context.key).to eq('test-key')
|
390
|
+
expect(context.user).to eq(user)
|
391
|
+
expect(context.default_value).to eq('test-default')
|
392
|
+
end
|
393
|
+
|
394
|
+
it 'allows modification of attributes' do
|
395
|
+
context = DevCycle::HookContext.new(
|
396
|
+
key: 'original-key',
|
397
|
+
user: 'original-user',
|
398
|
+
default_value: 'original-default'
|
399
|
+
)
|
400
|
+
|
401
|
+
context.key = 'modified-key'
|
402
|
+
context.user = 'modified-user'
|
403
|
+
context.default_value = 'modified-default'
|
404
|
+
|
405
|
+
expect(context.key).to eq('modified-key')
|
406
|
+
expect(context.user).to eq('modified-user')
|
407
|
+
expect(context.default_value).to eq('modified-default')
|
408
|
+
end
|
409
|
+
end
|
410
|
+
end
|
@@ -0,0 +1,245 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe DevCycle::Client do
|
4
|
+
let(:test_user) { DevCycle::User.new(user_id: 'test-user', email: 'test@example.com') }
|
5
|
+
let(:test_key) { 'test-variable' }
|
6
|
+
let(:test_default) { 'default-value' }
|
7
|
+
let(:options) { DevCycle::Options.new }
|
8
|
+
|
9
|
+
# Use unique SDK keys for each test to avoid WASM initialization conflicts
|
10
|
+
let(:valid_sdk_key) { "server-test-key-#{SecureRandom.hex(4)}" }
|
11
|
+
let(:client) { DevCycle::Client.new(valid_sdk_key, options) }
|
12
|
+
|
13
|
+
after(:each) do
|
14
|
+
client.close if client.respond_to?(:close)
|
15
|
+
end
|
16
|
+
|
17
|
+
describe 'eval hooks functionality' do
|
18
|
+
context 'hook management' do
|
19
|
+
it 'initializes with an empty eval hooks runner' do
|
20
|
+
expect(client.instance_variable_get(:@eval_hooks_runner)).to be_a(DevCycle::EvalHooksRunner)
|
21
|
+
expect(client.instance_variable_get(:@eval_hooks_runner).eval_hooks).to be_empty
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'can add eval hooks' do
|
25
|
+
hook = DevCycle::EvalHook.new
|
26
|
+
client.add_eval_hook(hook)
|
27
|
+
expect(client.instance_variable_get(:@eval_hooks_runner).eval_hooks).to include(hook)
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'can clear eval hooks' do
|
31
|
+
hook = DevCycle::EvalHook.new
|
32
|
+
client.add_eval_hook(hook)
|
33
|
+
expect(client.instance_variable_get(:@eval_hooks_runner).eval_hooks).not_to be_empty
|
34
|
+
|
35
|
+
client.clear_eval_hooks
|
36
|
+
expect(client.instance_variable_get(:@eval_hooks_runner).eval_hooks).to be_empty
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
context 'variable evaluation with hooks' do
|
41
|
+
it 'runs before hooks before variable evaluation' do
|
42
|
+
before_hook_called = false
|
43
|
+
hook = DevCycle::EvalHook.new(
|
44
|
+
before: ->(context) {
|
45
|
+
before_hook_called = true
|
46
|
+
expect(context.key).to eq(test_key)
|
47
|
+
expect(context.user).to eq(test_user)
|
48
|
+
expect(context.default_value).to eq(test_default)
|
49
|
+
context
|
50
|
+
}
|
51
|
+
)
|
52
|
+
client.add_eval_hook(hook)
|
53
|
+
|
54
|
+
result = client.variable(test_user, test_key, test_default)
|
55
|
+
expect(before_hook_called).to be true
|
56
|
+
expect(result.isDefaulted).to be true
|
57
|
+
expect(result.value).to eq(test_default)
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'runs after hooks after successful variable evaluation' do
|
61
|
+
after_hook_called = false
|
62
|
+
hook = DevCycle::EvalHook.new(
|
63
|
+
after: ->(context) {
|
64
|
+
after_hook_called = true
|
65
|
+
expect(context.key).to eq(test_key)
|
66
|
+
expect(context.user).to eq(test_user)
|
67
|
+
expect(context.default_value).to eq(test_default)
|
68
|
+
}
|
69
|
+
)
|
70
|
+
client.add_eval_hook(hook)
|
71
|
+
|
72
|
+
result = client.variable(test_user, test_key, test_default)
|
73
|
+
expect(after_hook_called).to be true
|
74
|
+
expect(result.isDefaulted).to be true
|
75
|
+
expect(result.value).to eq(test_default)
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'runs error hooks when variable evaluation fails' do
|
79
|
+
error_hook_called = false
|
80
|
+
hook = DevCycle::EvalHook.new(
|
81
|
+
error: ->(context, error) {
|
82
|
+
error_hook_called = true
|
83
|
+
expect(context.key).to eq(test_key)
|
84
|
+
expect(context.user).to eq(test_user)
|
85
|
+
expect(context.default_value).to eq(test_default)
|
86
|
+
}
|
87
|
+
)
|
88
|
+
client.add_eval_hook(hook)
|
89
|
+
|
90
|
+
# Force an error by making determine_variable_type raise an error
|
91
|
+
allow(client).to receive(:determine_variable_type).and_raise(StandardError, 'Variable type error')
|
92
|
+
|
93
|
+
client.variable(test_user, test_key, test_default)
|
94
|
+
expect(error_hook_called).to be true
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'runs finally hooks regardless of success or failure' do
|
98
|
+
finally_hook_called = false
|
99
|
+
hook = DevCycle::EvalHook.new(
|
100
|
+
on_finally: ->(context) {
|
101
|
+
finally_hook_called = true
|
102
|
+
expect(context.key).to eq(test_key)
|
103
|
+
expect(context.user).to eq(test_user)
|
104
|
+
expect(context.default_value).to eq(test_default)
|
105
|
+
}
|
106
|
+
)
|
107
|
+
client.add_eval_hook(hook)
|
108
|
+
|
109
|
+
result = client.variable(test_user, test_key, test_default)
|
110
|
+
expect(finally_hook_called).to be true
|
111
|
+
expect(result.isDefaulted).to be true
|
112
|
+
expect(result.value).to eq(test_default)
|
113
|
+
end
|
114
|
+
|
115
|
+
it 'skips after hooks when before hook raises an error' do
|
116
|
+
before_hook_called = false
|
117
|
+
after_hook_called = false
|
118
|
+
error_hook_called = false
|
119
|
+
finally_hook_called = false
|
120
|
+
|
121
|
+
hook = DevCycle::EvalHook.new(
|
122
|
+
before: ->(context) {
|
123
|
+
before_hook_called = true
|
124
|
+
raise StandardError, 'Before hook error'
|
125
|
+
},
|
126
|
+
after: ->(context) {
|
127
|
+
after_hook_called = true
|
128
|
+
},
|
129
|
+
error: ->(context, error) {
|
130
|
+
error_hook_called = true
|
131
|
+
expect(error).to be_a(StandardError)
|
132
|
+
expect(error.message).to include('Before hook error')
|
133
|
+
},
|
134
|
+
on_finally: ->(context) {
|
135
|
+
finally_hook_called = true
|
136
|
+
}
|
137
|
+
)
|
138
|
+
client.add_eval_hook(hook)
|
139
|
+
|
140
|
+
client.variable(test_user, test_key, test_default)
|
141
|
+
expect(before_hook_called).to be true
|
142
|
+
expect(after_hook_called).to be false
|
143
|
+
expect(error_hook_called).to be true
|
144
|
+
expect(finally_hook_called).to be true
|
145
|
+
end
|
146
|
+
|
147
|
+
it 'runs multiple hooks in order' do
|
148
|
+
execution_order = []
|
149
|
+
|
150
|
+
hook1 = DevCycle::EvalHook.new(
|
151
|
+
before: ->(context) {
|
152
|
+
execution_order << 'hook1_before'
|
153
|
+
context
|
154
|
+
},
|
155
|
+
after: ->(context) {
|
156
|
+
execution_order << 'hook1_after'
|
157
|
+
},
|
158
|
+
on_finally: ->(context) {
|
159
|
+
execution_order << 'hook1_finally'
|
160
|
+
}
|
161
|
+
)
|
162
|
+
|
163
|
+
hook2 = DevCycle::EvalHook.new(
|
164
|
+
before: ->(context) {
|
165
|
+
execution_order << 'hook2_before'
|
166
|
+
context
|
167
|
+
},
|
168
|
+
after: ->(context) {
|
169
|
+
execution_order << 'hook2_after'
|
170
|
+
},
|
171
|
+
on_finally: ->(context) {
|
172
|
+
execution_order << 'hook2_finally'
|
173
|
+
}
|
174
|
+
)
|
175
|
+
|
176
|
+
client.add_eval_hook(hook1)
|
177
|
+
client.add_eval_hook(hook2)
|
178
|
+
|
179
|
+
result = client.variable(test_user, test_key, test_default)
|
180
|
+
|
181
|
+
expect(execution_order).to eq([
|
182
|
+
'hook1_before', 'hook2_before',
|
183
|
+
'hook1_after', 'hook2_after',
|
184
|
+
'hook1_finally', 'hook2_finally'
|
185
|
+
])
|
186
|
+
expect(result.isDefaulted).to be true
|
187
|
+
expect(result.value).to eq(test_default)
|
188
|
+
end
|
189
|
+
|
190
|
+
it 'allows before hooks to modify context' do
|
191
|
+
modified_context = nil
|
192
|
+
hook = DevCycle::EvalHook.new(
|
193
|
+
before: ->(context) {
|
194
|
+
# Modify the context
|
195
|
+
context.key = 'modified-key'
|
196
|
+
context.user = DevCycle::User.new(user_id: 'modified-user', email: 'modified@example.com')
|
197
|
+
context
|
198
|
+
},
|
199
|
+
after: ->(context) {
|
200
|
+
modified_context = context
|
201
|
+
}
|
202
|
+
)
|
203
|
+
client.add_eval_hook(hook)
|
204
|
+
|
205
|
+
result = client.variable(test_user, test_key, test_default)
|
206
|
+
|
207
|
+
expect(modified_context.key).to eq('modified-key')
|
208
|
+
expect(modified_context.user).to eq(DevCycle::User.new(user_id: 'modified-user', email: 'modified@example.com'))
|
209
|
+
expect(result.isDefaulted).to be true
|
210
|
+
expect(result.value).to eq(test_default)
|
211
|
+
end
|
212
|
+
|
213
|
+
it 'works with different variable types' do
|
214
|
+
# Test with boolean default
|
215
|
+
boolean_hook_called = false
|
216
|
+
boolean_hook = DevCycle::EvalHook.new(
|
217
|
+
after: ->(context) {
|
218
|
+
boolean_hook_called = true
|
219
|
+
}
|
220
|
+
)
|
221
|
+
client.add_eval_hook(boolean_hook)
|
222
|
+
|
223
|
+
boolean_result = client.variable(test_user, 'boolean-test', true)
|
224
|
+
expect(boolean_hook_called).to be true
|
225
|
+
expect(boolean_result.isDefaulted).to be true
|
226
|
+
expect(boolean_result.value).to eq(true)
|
227
|
+
|
228
|
+
# Test with number default
|
229
|
+
number_hook_called = false
|
230
|
+
number_hook = DevCycle::EvalHook.new(
|
231
|
+
after: ->(context) {
|
232
|
+
number_hook_called = true
|
233
|
+
}
|
234
|
+
)
|
235
|
+
client.add_eval_hook(number_hook)
|
236
|
+
|
237
|
+
number_result = client.variable(test_user, 'number-test', 42)
|
238
|
+
expect(number_hook_called).to be true
|
239
|
+
expect(number_result.isDefaulted).to be true
|
240
|
+
expect(number_result.value).to eq(42)
|
241
|
+
end
|
242
|
+
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: devcycle-ruby-server-sdk
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.6.
|
4
|
+
version: 3.6.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- DevCycleHQ
|
@@ -165,6 +165,7 @@ files:
|
|
165
165
|
- lib/devcycle-ruby-server-sdk/api_client.rb
|
166
166
|
- lib/devcycle-ruby-server-sdk/api_error.rb
|
167
167
|
- lib/devcycle-ruby-server-sdk/configuration.rb
|
168
|
+
- lib/devcycle-ruby-server-sdk/eval_hooks_runner.rb
|
168
169
|
- lib/devcycle-ruby-server-sdk/localbucketing/bucketed_user_config.rb
|
169
170
|
- lib/devcycle-ruby-server-sdk/localbucketing/bucketing-lib.release.wasm
|
170
171
|
- lib/devcycle-ruby-server-sdk/localbucketing/config_manager.rb
|
@@ -179,6 +180,8 @@ files:
|
|
179
180
|
- lib/devcycle-ruby-server-sdk/localbucketing/proto/variableForUserParams_pb.rb
|
180
181
|
- lib/devcycle-ruby-server-sdk/localbucketing/update_wasm.sh
|
181
182
|
- lib/devcycle-ruby-server-sdk/models/error_response.rb
|
183
|
+
- lib/devcycle-ruby-server-sdk/models/eval_hook.rb
|
184
|
+
- lib/devcycle-ruby-server-sdk/models/eval_hook_context.rb
|
182
185
|
- lib/devcycle-ruby-server-sdk/models/event.rb
|
183
186
|
- lib/devcycle-ruby-server-sdk/models/feature.rb
|
184
187
|
- lib/devcycle-ruby-server-sdk/models/inline_response201.rb
|
@@ -190,6 +193,8 @@ files:
|
|
190
193
|
- spec/api_client_spec.rb
|
191
194
|
- spec/configuration_spec.rb
|
192
195
|
- spec/devcycle_provider_spec.rb
|
196
|
+
- spec/eval_hooks_runner_spec.rb
|
197
|
+
- spec/eval_hooks_spec.rb
|
193
198
|
- spec/spec_helper.rb
|
194
199
|
homepage: https://devcycle.com
|
195
200
|
licenses:
|
@@ -209,7 +214,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
209
214
|
- !ruby/object:Gem::Version
|
210
215
|
version: '0'
|
211
216
|
requirements: []
|
212
|
-
rubygems_version: 3.6.
|
217
|
+
rubygems_version: 3.6.9
|
213
218
|
specification_version: 4
|
214
219
|
summary: DevCycle Bucketing API Ruby Gem
|
215
220
|
test_files:
|
@@ -217,4 +222,6 @@ test_files:
|
|
217
222
|
- spec/api_client_spec.rb
|
218
223
|
- spec/configuration_spec.rb
|
219
224
|
- spec/devcycle_provider_spec.rb
|
225
|
+
- spec/eval_hooks_runner_spec.rb
|
226
|
+
- spec/eval_hooks_spec.rb
|
220
227
|
- spec/spec_helper.rb
|