togglecraft 1.0.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 +7 -0
- data/CHANGELOG.md +108 -0
- data/LICENSE +18 -0
- data/README.md +347 -0
- data/lib/togglecraft/cache.rb +151 -0
- data/lib/togglecraft/cache_adapters/memory_adapter.rb +54 -0
- data/lib/togglecraft/client.rb +568 -0
- data/lib/togglecraft/connection_pool.rb +134 -0
- data/lib/togglecraft/evaluator.rb +309 -0
- data/lib/togglecraft/shared_sse_connection.rb +266 -0
- data/lib/togglecraft/sse_connection.rb +296 -0
- data/lib/togglecraft/utils.rb +179 -0
- data/lib/togglecraft/version.rb +5 -0
- data/lib/togglecraft.rb +19 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/togglecraft/cache/memory_adapter_spec.rb +128 -0
- data/spec/togglecraft/cache_spec.rb +274 -0
- data/spec/togglecraft/client_spec.rb +728 -0
- data/spec/togglecraft/connection_pool_spec.rb +178 -0
- data/spec/togglecraft/evaluator_spec.rb +443 -0
- data/spec/togglecraft/shared_sse_connection_spec.rb +585 -0
- data/spec/togglecraft/sse_connection_spec.rb +691 -0
- data/spec/togglecraft/utils_spec.rb +506 -0
- metadata +151 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe ToggleCraft::Client do
|
|
4
|
+
let(:sdk_key) { 'test-sdk-key-12345' }
|
|
5
|
+
let(:client) { described_class.new(sdk_key: sdk_key, debug: false) }
|
|
6
|
+
|
|
7
|
+
after do
|
|
8
|
+
client.destroy if client
|
|
9
|
+
ToggleCraft::ConnectionPool.clear(force: true)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe '#initialize' do
|
|
13
|
+
it 'requires sdk_key parameter' do
|
|
14
|
+
expect { described_class.new }.to raise_error(ArgumentError, /sdk_key/)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'raises error when sdk_key is nil' do
|
|
18
|
+
expect { described_class.new(sdk_key: nil) }.to raise_error(ArgumentError, 'sdk_key is required')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'raises error when sdk_key is empty string' do
|
|
22
|
+
expect { described_class.new(sdk_key: '') }.to raise_error(ArgumentError, 'sdk_key is required')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'sets default configuration options' do
|
|
26
|
+
expect(client.config[:url]).to eq('https://sse.togglecraft.io')
|
|
27
|
+
expect(client.config[:enable_cache]).to be true
|
|
28
|
+
expect(client.config[:cache_adapter]).to eq(:memory)
|
|
29
|
+
expect(client.config[:cache_ttl]).to eq(300)
|
|
30
|
+
expect(client.config[:reconnect_interval]).to eq(1)
|
|
31
|
+
expect(client.config[:max_reconnect_interval]).to eq(30)
|
|
32
|
+
expect(client.config[:max_reconnect_attempts]).to eq(10)
|
|
33
|
+
expect(client.config[:slow_reconnect_interval]).to eq(60)
|
|
34
|
+
expect(client.config[:share_connection]).to be true
|
|
35
|
+
expect(client.config[:debug]).to be false
|
|
36
|
+
expect(client.config[:enable_rollout_stage_polling]).to be true
|
|
37
|
+
expect(client.config[:rollout_stage_check_interval]).to eq(60)
|
|
38
|
+
expect(client.config[:fetch_jitter]).to eq(1500)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'allows custom configuration options' do
|
|
42
|
+
custom_client = described_class.new(
|
|
43
|
+
sdk_key: 'custom-key',
|
|
44
|
+
url: 'https://custom.example.com',
|
|
45
|
+
enable_cache: false,
|
|
46
|
+
cache_ttl: 600,
|
|
47
|
+
reconnect_interval: 5,
|
|
48
|
+
share_connection: false,
|
|
49
|
+
debug: true
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
expect(custom_client.config[:url]).to eq('https://custom.example.com')
|
|
53
|
+
expect(custom_client.config[:enable_cache]).to be false
|
|
54
|
+
expect(custom_client.config[:cache_ttl]).to eq(600)
|
|
55
|
+
expect(custom_client.config[:reconnect_interval]).to eq(5)
|
|
56
|
+
expect(custom_client.config[:share_connection]).to be false
|
|
57
|
+
expect(custom_client.config[:debug]).to be true
|
|
58
|
+
|
|
59
|
+
custom_client.destroy
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'initializes ready state to false' do
|
|
63
|
+
expect(client.ready?).to be false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'initializes evaluator' do
|
|
67
|
+
evaluator = client.instance_variable_get(:@evaluator)
|
|
68
|
+
expect(evaluator).to be_a(ToggleCraft::Evaluator)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'initializes cache when enabled' do
|
|
72
|
+
cache = client.instance_variable_get(:@cache)
|
|
73
|
+
expect(cache).to be_a(ToggleCraft::Cache)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'does not initialize cache when disabled' do
|
|
77
|
+
no_cache_client = described_class.new(sdk_key: 'key', enable_cache: false)
|
|
78
|
+
cache = no_cache_client.instance_variable_get(:@cache)
|
|
79
|
+
expect(cache).to be_nil
|
|
80
|
+
no_cache_client.destroy
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'initializes event listeners map' do
|
|
84
|
+
listeners = client.instance_variable_get(:@listeners)
|
|
85
|
+
expect(listeners.keys).to include(:ready, :flags_updated, :error, :disconnected, :reconnecting, :rollout_stage_changed)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'initializes connection (shared by default)' do
|
|
89
|
+
connection = client.instance_variable_get(:@connection)
|
|
90
|
+
expect(connection).to be_a(ToggleCraft::SharedSSEConnection)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'creates dedicated connection when share_connection is false' do
|
|
94
|
+
dedicated_client = described_class.new(sdk_key: 'key', share_connection: false)
|
|
95
|
+
connection = dedicated_client.instance_variable_get(:@connection)
|
|
96
|
+
expect(connection).to be_a(ToggleCraft::SSEConnection)
|
|
97
|
+
dedicated_client.destroy
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe '#ready?' do
|
|
102
|
+
it 'returns false initially' do
|
|
103
|
+
expect(client.ready?).to be false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'returns true after ready state is set' do
|
|
107
|
+
client.instance_variable_get(:@ready).make_true
|
|
108
|
+
expect(client.ready?).to be true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'returns false after disconnect' do
|
|
112
|
+
client.instance_variable_get(:@ready).make_true
|
|
113
|
+
client.handle_disconnect
|
|
114
|
+
expect(client.ready?).to be false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe '#connected?' do
|
|
119
|
+
it 'returns false when connection is nil' do
|
|
120
|
+
client.instance_variable_set(:@connection, nil)
|
|
121
|
+
expect(client.connected?).to be false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'returns connection status when connection exists' do
|
|
125
|
+
connection = client.instance_variable_get(:@connection)
|
|
126
|
+
allow(connection).to receive(:connected?).and_return(true)
|
|
127
|
+
expect(client.connected?).to be true
|
|
128
|
+
|
|
129
|
+
allow(connection).to receive(:connected?).and_return(false)
|
|
130
|
+
expect(client.connected?).to be false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe '#wait_for_ready' do
|
|
135
|
+
it 'returns immediately if already ready' do
|
|
136
|
+
client.instance_variable_get(:@ready).make_true
|
|
137
|
+
expect { client.wait_for_ready(timeout: 1) }.not_to raise_error
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'waits for ready event' do
|
|
141
|
+
Thread.new do
|
|
142
|
+
sleep 0.1
|
|
143
|
+
client.instance_variable_get(:@ready).make_true
|
|
144
|
+
client.send(:emit, :ready)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
expect { client.wait_for_ready(timeout: 2) }.not_to raise_error
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'raises timeout error if not ready within timeout' do
|
|
151
|
+
expect do
|
|
152
|
+
client.wait_for_ready(timeout: 0.1)
|
|
153
|
+
end.to raise_error(Timeout::Error, /Timeout waiting for client to be ready/)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'attempts to clean up handler after timeout' do
|
|
157
|
+
# Test that timeout occurs without causing issues
|
|
158
|
+
expect do
|
|
159
|
+
client.wait_for_ready(timeout: 0.1)
|
|
160
|
+
end.to raise_error(Timeout::Error)
|
|
161
|
+
|
|
162
|
+
# Client should still be functional after timeout
|
|
163
|
+
expect(client.ready?).to be false
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
describe 'flag evaluation methods' do
|
|
168
|
+
before do
|
|
169
|
+
flags = {
|
|
170
|
+
'boolean-flag' => {
|
|
171
|
+
type: 'boolean',
|
|
172
|
+
enabled: true,
|
|
173
|
+
value: true
|
|
174
|
+
},
|
|
175
|
+
'multivariate-flag' => {
|
|
176
|
+
type: 'multivariate',
|
|
177
|
+
enabled: true,
|
|
178
|
+
default_variant: 'control',
|
|
179
|
+
variants: %w[control variant-a],
|
|
180
|
+
weights: { 'control' => 50, 'variant-a' => 50 }
|
|
181
|
+
},
|
|
182
|
+
'percentage-flag' => {
|
|
183
|
+
type: 'percentage',
|
|
184
|
+
enabled: true,
|
|
185
|
+
percentage: 50
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
evaluator = client.instance_variable_get(:@evaluator)
|
|
189
|
+
evaluator.update_flags(flags)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
describe '#enabled?' do
|
|
193
|
+
it 'evaluates boolean flags' do
|
|
194
|
+
result = client.enabled?('boolean-flag')
|
|
195
|
+
expect(result).to be true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'returns default value for missing flags' do
|
|
199
|
+
expect(client.enabled?('missing', {}, default: false)).to be false
|
|
200
|
+
expect(client.enabled?('missing', {}, default: true)).to be true
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'accepts context for evaluation' do
|
|
204
|
+
context = { user: { id: '123', role: 'admin' } }
|
|
205
|
+
result = client.enabled?('boolean-flag', context)
|
|
206
|
+
expect([true, false]).to include(result)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'caches evaluation results when cache is enabled' do
|
|
210
|
+
context = { user: { id: '123' } }
|
|
211
|
+
cache = client.instance_variable_get(:@cache)
|
|
212
|
+
|
|
213
|
+
client.enabled?('boolean-flag', context)
|
|
214
|
+
|
|
215
|
+
# Check cache was called
|
|
216
|
+
expect(cache.instance_variable_get(:@adapter).keys.any? { |k| k.start_with?('eval:') }).to be true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'does not cache when cache is disabled' do
|
|
220
|
+
no_cache_client = described_class.new(sdk_key: 'key', enable_cache: false)
|
|
221
|
+
evaluator = no_cache_client.instance_variable_get(:@evaluator)
|
|
222
|
+
evaluator.update_flags({ 'test' => { type: 'boolean', enabled: true, value: true } })
|
|
223
|
+
|
|
224
|
+
result = no_cache_client.enabled?('test', { user: { id: '123' } })
|
|
225
|
+
expect(result).to be true
|
|
226
|
+
|
|
227
|
+
no_cache_client.destroy
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
describe '#variant' do
|
|
232
|
+
it 'evaluates multivariate flags' do
|
|
233
|
+
context = { user: { id: '123' } }
|
|
234
|
+
result = client.variant('multivariate-flag', context)
|
|
235
|
+
expect(%w[control variant-a]).to include(result)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it 'returns default value for missing flags' do
|
|
239
|
+
expect(client.variant('missing', {}, default: 'default')).to eq('default')
|
|
240
|
+
expect(client.variant('missing', {}, default: nil)).to be_nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'returns consistent variant for same user' do
|
|
244
|
+
context = { user: { id: 'user-123' } }
|
|
245
|
+
result1 = client.variant('multivariate-flag', context)
|
|
246
|
+
result2 = client.variant('multivariate-flag', context)
|
|
247
|
+
expect(result1).to eq(result2)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it 'caches evaluation results' do
|
|
251
|
+
context = { user: { id: '456' } }
|
|
252
|
+
cache = client.instance_variable_get(:@cache)
|
|
253
|
+
|
|
254
|
+
client.variant('multivariate-flag', context)
|
|
255
|
+
|
|
256
|
+
expect(cache.instance_variable_get(:@adapter).keys.any? { |k| k.start_with?('eval:') }).to be true
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
describe '#in_percentage?' do
|
|
261
|
+
it 'evaluates percentage flags' do
|
|
262
|
+
context = { user: { id: '123' } }
|
|
263
|
+
result = client.in_percentage?('percentage-flag', context)
|
|
264
|
+
expect([true, false]).to include(result)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
it 'returns default value for missing flags' do
|
|
268
|
+
expect(client.in_percentage?('missing', {}, default: false)).to be false
|
|
269
|
+
expect(client.in_percentage?('missing', {}, default: true)).to be true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it 'returns consistent result for same user' do
|
|
273
|
+
context = { user: { id: 'consistent-user' } }
|
|
274
|
+
result1 = client.in_percentage?('percentage-flag', context)
|
|
275
|
+
result2 = client.in_percentage?('percentage-flag', context)
|
|
276
|
+
expect(result1).to eq(result2)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
describe '#percentage' do
|
|
281
|
+
it 'returns current percentage for percentage flags' do
|
|
282
|
+
result = client.percentage('percentage-flag')
|
|
283
|
+
expect(result).to eq(50)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'returns nil for non-existent flags' do
|
|
287
|
+
expect(client.percentage('missing')).to be_nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it 'returns nil for non-percentage flags' do
|
|
291
|
+
expect(client.percentage('boolean-flag')).to be_nil
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
describe '#evaluate' do
|
|
296
|
+
it 'evaluates any flag type' do
|
|
297
|
+
expect(client.evaluate('boolean-flag')).to be true
|
|
298
|
+
expect(%w[control variant-a]).to include(client.evaluate('multivariate-flag', { user: { id: '123' } }))
|
|
299
|
+
result = client.evaluate('percentage-flag', { user: { id: '123' } })
|
|
300
|
+
expect([true, false]).to include(result)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
it 'returns default for missing flags' do
|
|
304
|
+
expect(client.evaluate('missing', {}, default: 'fallback')).to eq('fallback')
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
describe '#all_flag_keys' do
|
|
309
|
+
it 'returns all flag keys' do
|
|
310
|
+
keys = client.all_flag_keys
|
|
311
|
+
expect(keys).to include('boolean-flag', 'multivariate-flag', 'percentage-flag')
|
|
312
|
+
expect(keys.length).to eq(3)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'returns empty array when no flags' do
|
|
316
|
+
empty_client = described_class.new(sdk_key: 'key')
|
|
317
|
+
expect(empty_client.all_flag_keys).to eq([])
|
|
318
|
+
empty_client.destroy
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
describe '#flag_metadata' do
|
|
323
|
+
it 'returns metadata for existing flags' do
|
|
324
|
+
metadata = client.flag_metadata('boolean-flag')
|
|
325
|
+
expect(metadata[:type]).to eq('boolean')
|
|
326
|
+
expect(metadata[:enabled]).to be true
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
it 'returns nil for non-existent flags' do
|
|
330
|
+
expect(client.flag_metadata('missing')).to be_nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it 'includes type-specific metadata' do
|
|
334
|
+
mv_metadata = client.flag_metadata('multivariate-flag')
|
|
335
|
+
expect(mv_metadata[:variants]).to eq(%w[control variant-a])
|
|
336
|
+
expect(mv_metadata[:default_variant]).to eq('control')
|
|
337
|
+
|
|
338
|
+
pct_metadata = client.flag_metadata('percentage-flag')
|
|
339
|
+
expect(pct_metadata[:percentage]).to eq(50)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
describe '#has_flag?' do
|
|
344
|
+
it 'returns true for existing flags' do
|
|
345
|
+
expect(client.has_flag?('boolean-flag')).to be true
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it 'returns false for non-existent flags' do
|
|
349
|
+
expect(client.has_flag?('missing')).to be false
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
describe 'event system' do
|
|
355
|
+
describe '#on' do
|
|
356
|
+
it 'registers event listener' do
|
|
357
|
+
called = false
|
|
358
|
+
client.on(:ready) { called = true }
|
|
359
|
+
|
|
360
|
+
client.send(:emit, :ready)
|
|
361
|
+
expect(called).to be true
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it 'allows multiple listeners for same event' do
|
|
365
|
+
call_count = 0
|
|
366
|
+
client.on(:ready) { call_count += 1 }
|
|
367
|
+
client.on(:ready) { call_count += 1 }
|
|
368
|
+
|
|
369
|
+
client.send(:emit, :ready)
|
|
370
|
+
expect(call_count).to eq(2)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
it 'passes arguments to listeners' do
|
|
374
|
+
received_data = nil
|
|
375
|
+
client.on(:flags_updated) { |data| received_data = data }
|
|
376
|
+
|
|
377
|
+
test_data = { 'flag' => 'value' }
|
|
378
|
+
client.send(:emit, :flags_updated, test_data)
|
|
379
|
+
expect(received_data).to eq(test_data)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
it 'supports all event types' do
|
|
383
|
+
events_received = []
|
|
384
|
+
|
|
385
|
+
client.on(:ready) { events_received << :ready }
|
|
386
|
+
client.on(:flags_updated) { events_received << :flags_updated }
|
|
387
|
+
client.on(:error) { events_received << :error }
|
|
388
|
+
client.on(:disconnected) { events_received << :disconnected }
|
|
389
|
+
client.on(:reconnecting) { events_received << :reconnecting }
|
|
390
|
+
client.on(:rollout_stage_changed) { events_received << :rollout_stage_changed }
|
|
391
|
+
|
|
392
|
+
client.send(:emit, :ready)
|
|
393
|
+
client.send(:emit, :flags_updated, {})
|
|
394
|
+
client.send(:emit, :error, StandardError.new('test'))
|
|
395
|
+
client.send(:emit, :disconnected)
|
|
396
|
+
client.send(:emit, :reconnecting)
|
|
397
|
+
client.send(:emit, :rollout_stage_changed, {})
|
|
398
|
+
|
|
399
|
+
expect(events_received).to eq([:ready, :flags_updated, :error, :disconnected, :reconnecting, :rollout_stage_changed])
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it 'handles errors in event listeners gracefully' do
|
|
403
|
+
client.on(:ready) { raise 'Listener error' }
|
|
404
|
+
client.on(:ready) { } # This should still be called
|
|
405
|
+
|
|
406
|
+
expect { client.send(:emit, :ready) }.not_to raise_error
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
describe '#once' do
|
|
411
|
+
it 'registers one-time event listener' do
|
|
412
|
+
call_count = 0
|
|
413
|
+
client.once(:ready) { call_count += 1 }
|
|
414
|
+
|
|
415
|
+
client.send(:emit, :ready)
|
|
416
|
+
client.send(:emit, :ready)
|
|
417
|
+
|
|
418
|
+
expect(call_count).to eq(1)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
it 'receives event data' do
|
|
422
|
+
received = nil
|
|
423
|
+
client.once(:flags_updated) { |data| received = data }
|
|
424
|
+
|
|
425
|
+
client.send(:emit, :flags_updated, { test: 'data' })
|
|
426
|
+
expect(received).to eq({ test: 'data' })
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
it 'removes itself after execution' do
|
|
430
|
+
listeners_before = client.instance_variable_get(:@listeners)[:ready].length
|
|
431
|
+
|
|
432
|
+
client.once(:ready) { }
|
|
433
|
+
listeners_during = client.instance_variable_get(:@listeners)[:ready].length
|
|
434
|
+
expect(listeners_during).to eq(listeners_before + 1)
|
|
435
|
+
|
|
436
|
+
client.send(:emit, :ready)
|
|
437
|
+
listeners_after = client.instance_variable_get(:@listeners)[:ready].length
|
|
438
|
+
expect(listeners_after).to eq(listeners_before)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
describe '#off' do
|
|
443
|
+
it 'removes specific listener' do
|
|
444
|
+
handler = -> { }
|
|
445
|
+
client.on(:ready, &handler)
|
|
446
|
+
|
|
447
|
+
client.off(:ready, handler)
|
|
448
|
+
|
|
449
|
+
listeners = client.instance_variable_get(:@listeners)[:ready]
|
|
450
|
+
expect(listeners).not_to include(handler)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
it 'removes all listeners for event when no handler specified' do
|
|
454
|
+
client.on(:ready) { }
|
|
455
|
+
client.on(:ready) { }
|
|
456
|
+
|
|
457
|
+
client.off(:ready)
|
|
458
|
+
|
|
459
|
+
listeners = client.instance_variable_get(:@listeners)[:ready]
|
|
460
|
+
expect(listeners).to be_empty
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
it 'does not affect other events' do
|
|
464
|
+
client.on(:ready) { }
|
|
465
|
+
client.on(:error) { }
|
|
466
|
+
|
|
467
|
+
client.off(:ready)
|
|
468
|
+
|
|
469
|
+
expect(client.instance_variable_get(:@listeners)[:ready]).to be_empty
|
|
470
|
+
expect(client.instance_variable_get(:@listeners)[:error]).not_to be_empty
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
describe '#handle_flags_update' do
|
|
476
|
+
it 'processes full flags payload' do
|
|
477
|
+
payload = {
|
|
478
|
+
flags: {
|
|
479
|
+
'test-flag' => { type: 'boolean', enabled: true, value: true }
|
|
480
|
+
},
|
|
481
|
+
version: 1
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
client.handle_flags_update(payload)
|
|
485
|
+
|
|
486
|
+
expect(client.has_flag?('test-flag')).to be true
|
|
487
|
+
expect(client.instance_variable_get(:@current_version).get).to eq(1)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
it 'handles version_update type messages' do
|
|
491
|
+
allow(client).to receive(:handle_version_update)
|
|
492
|
+
|
|
493
|
+
payload = {
|
|
494
|
+
type: 'version_update',
|
|
495
|
+
version: 2,
|
|
496
|
+
project_key: 'project',
|
|
497
|
+
environment_key: 'production'
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
client.handle_flags_update(payload)
|
|
501
|
+
|
|
502
|
+
expect(client).to have_received(:handle_version_update).with(payload)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
it 'updates cache with new flags' do
|
|
506
|
+
cache = client.instance_variable_get(:@cache)
|
|
507
|
+
|
|
508
|
+
payload = {
|
|
509
|
+
flags: {
|
|
510
|
+
'cached-flag' => { type: 'boolean', enabled: true }
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
client.handle_flags_update(payload)
|
|
515
|
+
|
|
516
|
+
expect(cache.get('flag:cached-flag')).to eq({ type: 'boolean', enabled: true })
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
it 'emits ready event on first update' do
|
|
520
|
+
ready_called = false
|
|
521
|
+
client.on(:ready) { ready_called = true }
|
|
522
|
+
|
|
523
|
+
payload = {
|
|
524
|
+
flags: { 'flag' => { type: 'boolean', enabled: true } }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
client.handle_flags_update(payload)
|
|
528
|
+
|
|
529
|
+
expect(ready_called).to be true
|
|
530
|
+
expect(client.ready?).to be true
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
it 'emits flags_updated event' do
|
|
534
|
+
flags_received = nil
|
|
535
|
+
client.on(:flags_updated) { |flags| flags_received = flags }
|
|
536
|
+
|
|
537
|
+
payload = {
|
|
538
|
+
flags: { 'flag' => { type: 'boolean', enabled: true } }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
# Mark as ready first to avoid ready event
|
|
542
|
+
client.instance_variable_get(:@ready).make_true
|
|
543
|
+
|
|
544
|
+
client.handle_flags_update(payload)
|
|
545
|
+
|
|
546
|
+
expect(flags_received).to eq({ 'flag' => { type: 'boolean', enabled: true } })
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
describe '#handle_connect' do
|
|
551
|
+
it 'logs connection when debug is enabled' do
|
|
552
|
+
debug_client = described_class.new(sdk_key: 'key', debug: true)
|
|
553
|
+
|
|
554
|
+
expect { debug_client.handle_connect }.to output(/SSE connected/).to_stdout
|
|
555
|
+
|
|
556
|
+
debug_client.destroy
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
it 'does not raise errors' do
|
|
560
|
+
expect { client.handle_connect }.not_to raise_error
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
describe '#handle_disconnect' do
|
|
565
|
+
it 'sets ready state to false' do
|
|
566
|
+
client.instance_variable_get(:@ready).make_true
|
|
567
|
+
|
|
568
|
+
client.handle_disconnect
|
|
569
|
+
|
|
570
|
+
expect(client.ready?).to be false
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
it 'emits disconnected event' do
|
|
574
|
+
disconnected = false
|
|
575
|
+
client.on(:disconnected) { disconnected = true }
|
|
576
|
+
|
|
577
|
+
client.handle_disconnect
|
|
578
|
+
|
|
579
|
+
expect(disconnected).to be true
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
describe '#handle_error' do
|
|
584
|
+
it 'emits error event' do
|
|
585
|
+
error_received = nil
|
|
586
|
+
client.on(:error) { |e| error_received = e }
|
|
587
|
+
|
|
588
|
+
test_error = StandardError.new('Test error')
|
|
589
|
+
client.handle_error(test_error)
|
|
590
|
+
|
|
591
|
+
expect(error_received).to eq(test_error)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
it 'logs error when debug is enabled' do
|
|
595
|
+
debug_client = described_class.new(sdk_key: 'key', debug: true)
|
|
596
|
+
|
|
597
|
+
expect do
|
|
598
|
+
debug_client.handle_error(StandardError.new('Test'))
|
|
599
|
+
end.to output(/Error: Test/).to_stdout
|
|
600
|
+
|
|
601
|
+
debug_client.destroy
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
describe '#destroy' do
|
|
606
|
+
it 'stops rollout stage polling' do
|
|
607
|
+
client.instance_variable_set(:@rollout_stage_timer, Thread.new { sleep })
|
|
608
|
+
timer = client.instance_variable_get(:@rollout_stage_timer)
|
|
609
|
+
|
|
610
|
+
client.destroy
|
|
611
|
+
|
|
612
|
+
sleep 0.1
|
|
613
|
+
expect(timer.alive?).to be false
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
it 'clears all listeners' do
|
|
617
|
+
client.on(:ready) { }
|
|
618
|
+
client.on(:error) { }
|
|
619
|
+
|
|
620
|
+
client.destroy
|
|
621
|
+
|
|
622
|
+
listeners = client.instance_variable_get(:@listeners)
|
|
623
|
+
expect(listeners[:ready]).to be_empty
|
|
624
|
+
expect(listeners[:error]).to be_empty
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
it 'clears flags' do
|
|
628
|
+
evaluator = client.instance_variable_get(:@evaluator)
|
|
629
|
+
evaluator.update_flags({ 'flag' => { type: 'boolean' } })
|
|
630
|
+
|
|
631
|
+
expect(client.all_flag_keys).not_to be_empty
|
|
632
|
+
|
|
633
|
+
flags = client.instance_variable_get(:@flags)
|
|
634
|
+
flags.clear
|
|
635
|
+
|
|
636
|
+
expect(client.instance_variable_get(:@flags)).to be_empty
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
it 'sets ready to false' do
|
|
640
|
+
client.instance_variable_get(:@ready).make_true
|
|
641
|
+
|
|
642
|
+
client.destroy
|
|
643
|
+
|
|
644
|
+
expect(client.ready?).to be false
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
it 'destroys cache when present' do
|
|
648
|
+
cache = client.instance_variable_get(:@cache)
|
|
649
|
+
expect(cache).to receive(:destroy).at_least(:once).and_call_original
|
|
650
|
+
|
|
651
|
+
client.destroy
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
it 'is safe to call multiple times' do
|
|
655
|
+
expect { client.destroy }.not_to raise_error
|
|
656
|
+
expect { client.destroy }.not_to raise_error
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
describe 'thread safety' do
|
|
661
|
+
it 'handles concurrent flag evaluations' do
|
|
662
|
+
evaluator = client.instance_variable_get(:@evaluator)
|
|
663
|
+
evaluator.update_flags({
|
|
664
|
+
'flag' => { type: 'boolean', enabled: true, value: true }
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
threads = 20.times.map do |i|
|
|
668
|
+
Thread.new do
|
|
669
|
+
100.times do
|
|
670
|
+
result = client.enabled?('flag', { user: { id: "user-#{i}" } })
|
|
671
|
+
expect(result).to be true
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
threads.each(&:join)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
it 'handles concurrent listener registration' do
|
|
680
|
+
threads = 10.times.map do
|
|
681
|
+
Thread.new do
|
|
682
|
+
10.times do
|
|
683
|
+
client.on(:ready) { }
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
threads.each(&:join)
|
|
689
|
+
|
|
690
|
+
listeners = client.instance_variable_get(:@listeners)[:ready]
|
|
691
|
+
expect(listeners.length).to eq(100)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
it 'handles concurrent event emission' do
|
|
695
|
+
call_count = Concurrent::AtomicFixnum.new(0)
|
|
696
|
+
client.on(:ready) { call_count.increment }
|
|
697
|
+
|
|
698
|
+
threads = 50.times.map do
|
|
699
|
+
Thread.new do
|
|
700
|
+
client.send(:emit, :ready)
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
threads.each(&:join)
|
|
705
|
+
expect(call_count.value).to eq(50)
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
describe 'connection mode' do
|
|
710
|
+
it 'uses shared connection by default' do
|
|
711
|
+
expect(client.instance_variable_get(:@using_shared_connection)).to be true
|
|
712
|
+
expect(client.instance_variable_get(:@connection)).to be_a(ToggleCraft::SharedSSEConnection)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
it 'uses dedicated connection when share_connection is false' do
|
|
716
|
+
dedicated = described_class.new(sdk_key: 'key', share_connection: false)
|
|
717
|
+
expect(dedicated.instance_variable_get(:@using_shared_connection)).to be false
|
|
718
|
+
expect(dedicated.instance_variable_get(:@connection)).to be_a(ToggleCraft::SSEConnection)
|
|
719
|
+
dedicated.destroy
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
it 'enables debug on connection pool when debug is true and sharing is enabled' do
|
|
723
|
+
expect(ToggleCraft::ConnectionPool).to receive(:set_debug).with(true)
|
|
724
|
+
debug_client = described_class.new(sdk_key: 'key', debug: true, share_connection: true)
|
|
725
|
+
debug_client.destroy
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
end
|