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,585 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe ToggleCraft::SharedSSEConnection do
|
|
4
|
+
let(:config) do
|
|
5
|
+
{
|
|
6
|
+
url: 'https://test.togglecraft.io',
|
|
7
|
+
sdk_key: 'test-key-123',
|
|
8
|
+
reconnect_interval: 0.1,
|
|
9
|
+
max_reconnect_interval: 0.5,
|
|
10
|
+
heartbeat_interval: 1,
|
|
11
|
+
debug: false
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
let(:pool_key) { 'test-pool-key' }
|
|
15
|
+
let(:shared_connection) { described_class.new(config, pool_key) }
|
|
16
|
+
|
|
17
|
+
# Mock client that responds to callbacks
|
|
18
|
+
let(:mock_client) do
|
|
19
|
+
double('Client',
|
|
20
|
+
handle_connect: nil,
|
|
21
|
+
handle_flags_update: nil,
|
|
22
|
+
handle_disconnect: nil,
|
|
23
|
+
handle_error: nil,
|
|
24
|
+
emit: nil,
|
|
25
|
+
respond_to?: true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
after do
|
|
29
|
+
shared_connection.force_disconnect if shared_connection
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#initialize' do
|
|
33
|
+
it 'stores config and pool_key' do
|
|
34
|
+
expect(shared_connection.pool_key).to eq(pool_key)
|
|
35
|
+
expect(shared_connection.instance_variable_get(:@config)).to eq(config)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'initializes clients set' do
|
|
39
|
+
clients = shared_connection.instance_variable_get(:@clients)
|
|
40
|
+
expect(clients).to be_a(Concurrent::Set)
|
|
41
|
+
expect(clients).to be_empty
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'initializes state variables' do
|
|
45
|
+
expect(shared_connection.instance_variable_get(:@connection)).to be_nil
|
|
46
|
+
expect(shared_connection.instance_variable_get(:@last_payload)).to be_nil
|
|
47
|
+
expect(shared_connection.instance_variable_get(:@is_connecting).value).to be false
|
|
48
|
+
expect(shared_connection.instance_variable_get(:@connection_promise)).to be_nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'sets debug flag from config' do
|
|
52
|
+
debug_conn = described_class.new(config.merge(debug: true), pool_key)
|
|
53
|
+
expect(debug_conn.instance_variable_get(:@debug)).to be true
|
|
54
|
+
debug_conn.force_disconnect
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe '#add_client' do
|
|
59
|
+
it 'adds client to clients set' do
|
|
60
|
+
shared_connection.add_client(mock_client)
|
|
61
|
+
|
|
62
|
+
clients = shared_connection.instance_variable_get(:@clients)
|
|
63
|
+
expect(clients).to include(mock_client)
|
|
64
|
+
expect(shared_connection.client_count).to eq(1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'accepts multiple clients' do
|
|
68
|
+
client1 = double('Client1')
|
|
69
|
+
client2 = double('Client2')
|
|
70
|
+
|
|
71
|
+
shared_connection.add_client(client1)
|
|
72
|
+
shared_connection.add_client(client2)
|
|
73
|
+
|
|
74
|
+
expect(shared_connection.client_count).to eq(2)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'does not duplicate clients' do
|
|
78
|
+
shared_connection.add_client(mock_client)
|
|
79
|
+
shared_connection.add_client(mock_client)
|
|
80
|
+
|
|
81
|
+
expect(shared_connection.client_count).to eq(1)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
context 'when connection is already established' do
|
|
85
|
+
before do
|
|
86
|
+
# Simulate established connection
|
|
87
|
+
connection = double('SSEConnection', connected?: true, disconnect: nil)
|
|
88
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
89
|
+
shared_connection.instance_variable_set(:@last_payload, { flags: { 'test' => {} } })
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'simulates connection event for new client' do
|
|
93
|
+
expect(mock_client).to receive(:handle_connect)
|
|
94
|
+
shared_connection.add_client(mock_client)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'sends cached flags to new client' do
|
|
98
|
+
expect(mock_client).to receive(:handle_flags_update).with({ flags: { 'test' => {} } })
|
|
99
|
+
shared_connection.add_client(mock_client)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'does not send flags if client does not respond to handle_flags_update' do
|
|
103
|
+
basic_client = double('BasicClient', respond_to?: false)
|
|
104
|
+
expect(basic_client).not_to receive(:handle_flags_update)
|
|
105
|
+
shared_connection.add_client(basic_client)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe '#remove_client' do
|
|
111
|
+
before do
|
|
112
|
+
shared_connection.add_client(mock_client)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'removes client from clients set' do
|
|
116
|
+
shared_connection.remove_client(mock_client)
|
|
117
|
+
|
|
118
|
+
clients = shared_connection.instance_variable_get(:@clients)
|
|
119
|
+
expect(clients).not_to include(mock_client)
|
|
120
|
+
expect(shared_connection.client_count).to eq(0)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'handles removing non-existent client gracefully' do
|
|
124
|
+
other_client = double('OtherClient')
|
|
125
|
+
expect { shared_connection.remove_client(other_client) }.not_to raise_error
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
context 'when last client is removed' do
|
|
129
|
+
it 'triggers empty callback' do
|
|
130
|
+
callback_called = false
|
|
131
|
+
shared_connection.on_empty { callback_called = true }
|
|
132
|
+
|
|
133
|
+
shared_connection.remove_client(mock_client)
|
|
134
|
+
|
|
135
|
+
expect(callback_called).to be true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'calls cleanup method' do
|
|
139
|
+
expect(shared_connection).to receive(:cleanup).and_call_original
|
|
140
|
+
|
|
141
|
+
shared_connection.remove_client(mock_client)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
context 'when other clients remain' do
|
|
146
|
+
before do
|
|
147
|
+
client2 = double('Client2')
|
|
148
|
+
shared_connection.add_client(client2)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'does not trigger empty callback' do
|
|
152
|
+
callback_called = false
|
|
153
|
+
shared_connection.on_empty { callback_called = true }
|
|
154
|
+
|
|
155
|
+
shared_connection.remove_client(mock_client)
|
|
156
|
+
|
|
157
|
+
expect(callback_called).to be false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
describe '#connect' do
|
|
163
|
+
it 'creates new SSE connection' do
|
|
164
|
+
allow(shared_connection).to receive(:create_connection)
|
|
165
|
+
shared_connection.connect
|
|
166
|
+
|
|
167
|
+
expect(shared_connection).to have_received(:create_connection)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'adds client when provided' do
|
|
171
|
+
allow(shared_connection).to receive(:create_connection)
|
|
172
|
+
|
|
173
|
+
shared_connection.connect(mock_client)
|
|
174
|
+
|
|
175
|
+
expect(shared_connection.client_count).to eq(1)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'sets is_connecting flag during connection' do
|
|
179
|
+
original_create = shared_connection.method(:create_connection)
|
|
180
|
+
|
|
181
|
+
allow(shared_connection).to receive(:create_connection) do
|
|
182
|
+
# Check flag is set during connection
|
|
183
|
+
expect(shared_connection.instance_variable_get(:@is_connecting).value).to be true
|
|
184
|
+
original_create.call
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
shared_connection.connect
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'clears is_connecting flag after connection' do
|
|
191
|
+
shared_connection.connect
|
|
192
|
+
|
|
193
|
+
expect(shared_connection.instance_variable_get(:@is_connecting).value).to be false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
context 'when already connected' do
|
|
197
|
+
before do
|
|
198
|
+
connection = double('SSEConnection', connected?: true, connect: nil, disconnect: nil)
|
|
199
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'does not create new connection' do
|
|
203
|
+
expect(shared_connection).not_to receive(:create_connection)
|
|
204
|
+
shared_connection.connect
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'adds client if provided' do
|
|
208
|
+
shared_connection.connect(mock_client)
|
|
209
|
+
expect(shared_connection.client_count).to eq(1)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
context 'when connection is in progress' do
|
|
214
|
+
it 'waits instead of creating duplicate connection' do
|
|
215
|
+
# Simulate connection in progress
|
|
216
|
+
shared_connection.instance_variable_get(:@is_connecting).make_true
|
|
217
|
+
|
|
218
|
+
expect(shared_connection).not_to receive(:create_connection)
|
|
219
|
+
shared_connection.connect(mock_client)
|
|
220
|
+
|
|
221
|
+
# Should still add the client
|
|
222
|
+
expect(shared_connection.client_count).to eq(1)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
describe '#disconnect' do
|
|
228
|
+
it 'calls disconnect on underlying connection when present' do
|
|
229
|
+
connection = double('SSEConnection', connect: nil, disconnect: nil)
|
|
230
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
231
|
+
|
|
232
|
+
expect(connection).to receive(:disconnect).at_least(:once)
|
|
233
|
+
shared_connection.disconnect
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'does not raise error when connection is nil' do
|
|
237
|
+
shared_connection.instance_variable_set(:@connection, nil)
|
|
238
|
+
|
|
239
|
+
expect { shared_connection.disconnect }.not_to raise_error
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
describe '#force_disconnect' do
|
|
244
|
+
it 'disconnects connection' do
|
|
245
|
+
connection = double('SSEConnection', connect: nil, disconnect: nil)
|
|
246
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
247
|
+
|
|
248
|
+
expect(connection).to receive(:disconnect)
|
|
249
|
+
shared_connection.force_disconnect
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it 'clears all clients' do
|
|
253
|
+
shared_connection.add_client(mock_client)
|
|
254
|
+
shared_connection.add_client(double('Client2'))
|
|
255
|
+
|
|
256
|
+
shared_connection.force_disconnect
|
|
257
|
+
|
|
258
|
+
expect(shared_connection.client_count).to eq(0)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
describe '#reconnect' do
|
|
263
|
+
it 'notifies all clients about reconnection' do
|
|
264
|
+
shared_connection.add_client(mock_client)
|
|
265
|
+
|
|
266
|
+
expect(mock_client).to receive(:emit).with(:reconnecting)
|
|
267
|
+
shared_connection.reconnect
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it 'calls reconnect on existing connection' do
|
|
271
|
+
connection = double('SSEConnection', connect: nil, reconnect: nil, disconnect: nil)
|
|
272
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
273
|
+
|
|
274
|
+
expect(connection).to receive(:reconnect)
|
|
275
|
+
shared_connection.reconnect
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it 'creates new connection if none exists' do
|
|
279
|
+
shared_connection.instance_variable_set(:@connection, nil)
|
|
280
|
+
|
|
281
|
+
expect(shared_connection).to receive(:connect)
|
|
282
|
+
shared_connection.reconnect
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
describe '#connected?' do
|
|
287
|
+
it 'returns nil/falsey when no connection' do
|
|
288
|
+
# Clear connection first to avoid force_disconnect issues
|
|
289
|
+
shared_connection.instance_variable_set(:@connection, nil)
|
|
290
|
+
|
|
291
|
+
# Returns nil due to safe navigation operator
|
|
292
|
+
expect(shared_connection.connected?).to be_falsey
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it 'delegates to connection when present' do
|
|
296
|
+
connection = double('SSEConnection', connected?: true, disconnect: nil)
|
|
297
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
298
|
+
|
|
299
|
+
expect(shared_connection.connected?).to be true
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
describe '#client_count' do
|
|
304
|
+
it 'returns 0 when no clients' do
|
|
305
|
+
expect(shared_connection.client_count).to eq(0)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it 'returns correct count with multiple clients' do
|
|
309
|
+
shared_connection.add_client(mock_client)
|
|
310
|
+
shared_connection.add_client(double('Client2'))
|
|
311
|
+
shared_connection.add_client(double('Client3'))
|
|
312
|
+
|
|
313
|
+
expect(shared_connection.client_count).to eq(3)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
describe '#on_empty' do
|
|
318
|
+
it 'registers callback' do
|
|
319
|
+
callback = -> { }
|
|
320
|
+
shared_connection.on_empty(&callback)
|
|
321
|
+
|
|
322
|
+
expect(shared_connection.instance_variable_get(:@on_empty_callback)).to eq(callback)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it 'replaces previous callback' do
|
|
326
|
+
callback1 = -> { }
|
|
327
|
+
callback2 = -> { }
|
|
328
|
+
|
|
329
|
+
shared_connection.on_empty(&callback1)
|
|
330
|
+
shared_connection.on_empty(&callback2)
|
|
331
|
+
|
|
332
|
+
expect(shared_connection.instance_variable_get(:@on_empty_callback)).to eq(callback2)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
describe 'message broadcasting' do
|
|
337
|
+
before do
|
|
338
|
+
shared_connection.add_client(mock_client)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
describe 'handle_message' do
|
|
342
|
+
it 'broadcasts message to all clients' do
|
|
343
|
+
client2 = double('Client2', handle_flags_update: nil, respond_to?: true)
|
|
344
|
+
shared_connection.add_client(client2)
|
|
345
|
+
|
|
346
|
+
payload = { flags: { 'test' => {} } }
|
|
347
|
+
|
|
348
|
+
expect(mock_client).to receive(:handle_flags_update).with(payload)
|
|
349
|
+
expect(client2).to receive(:handle_flags_update).with(payload)
|
|
350
|
+
|
|
351
|
+
shared_connection.send(:handle_message, payload)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it 'caches flags payload for late-joining clients' do
|
|
355
|
+
payload = { flags: { 'test-flag' => { enabled: true } } }
|
|
356
|
+
|
|
357
|
+
shared_connection.send(:handle_message, payload)
|
|
358
|
+
|
|
359
|
+
expect(shared_connection.instance_variable_get(:@last_payload)).to eq(payload)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it 'does not cache non-flags messages' do
|
|
363
|
+
payload = { type: 'heartbeat' }
|
|
364
|
+
|
|
365
|
+
shared_connection.send(:handle_message, payload)
|
|
366
|
+
|
|
367
|
+
expect(shared_connection.instance_variable_get(:@last_payload)).to be_nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it 'handles errors in client callbacks gracefully' do
|
|
371
|
+
failing_client = double('FailingClient', respond_to?: true)
|
|
372
|
+
allow(failing_client).to receive(:handle_flags_update).and_raise('Client error')
|
|
373
|
+
|
|
374
|
+
shared_connection.add_client(failing_client)
|
|
375
|
+
|
|
376
|
+
payload = { flags: {} }
|
|
377
|
+
|
|
378
|
+
# Should not raise error despite client failure
|
|
379
|
+
expect { shared_connection.send(:handle_message, payload) }.not_to raise_error
|
|
380
|
+
|
|
381
|
+
# Other clients should still receive message
|
|
382
|
+
expect(mock_client).to have_received(:handle_flags_update).with(payload)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
it 'skips clients that do not respond to handle_flags_update' do
|
|
386
|
+
non_responsive_client = double('NonResponsiveClient', respond_to?: false)
|
|
387
|
+
shared_connection.add_client(non_responsive_client)
|
|
388
|
+
|
|
389
|
+
payload = { flags: {} }
|
|
390
|
+
|
|
391
|
+
expect(non_responsive_client).not_to receive(:handle_flags_update)
|
|
392
|
+
shared_connection.send(:handle_message, payload)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
describe 'handle_connect' do
|
|
397
|
+
it 'notifies all clients of connection' do
|
|
398
|
+
client2 = double('Client2', handle_connect: nil, respond_to?: true)
|
|
399
|
+
shared_connection.add_client(client2)
|
|
400
|
+
|
|
401
|
+
expect(mock_client).to receive(:handle_connect)
|
|
402
|
+
expect(client2).to receive(:handle_connect)
|
|
403
|
+
|
|
404
|
+
shared_connection.send(:handle_connect)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it 'handles errors in client callbacks gracefully' do
|
|
408
|
+
failing_client = double('FailingClient', respond_to?: true)
|
|
409
|
+
allow(failing_client).to receive(:handle_connect).and_raise('Error')
|
|
410
|
+
|
|
411
|
+
shared_connection.add_client(failing_client)
|
|
412
|
+
|
|
413
|
+
expect { shared_connection.send(:handle_connect) }.not_to raise_error
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
describe 'handle_disconnect' do
|
|
418
|
+
it 'notifies all clients of disconnection' do
|
|
419
|
+
client2 = double('Client2', handle_disconnect: nil, respond_to?: true)
|
|
420
|
+
shared_connection.add_client(client2)
|
|
421
|
+
|
|
422
|
+
expect(mock_client).to receive(:handle_disconnect)
|
|
423
|
+
expect(client2).to receive(:handle_disconnect)
|
|
424
|
+
|
|
425
|
+
shared_connection.send(:handle_disconnect)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
it 'isolates errors between clients' do
|
|
429
|
+
failing_client = double('FailingClient', respond_to?: true)
|
|
430
|
+
allow(failing_client).to receive(:handle_disconnect).and_raise('Error')
|
|
431
|
+
|
|
432
|
+
client2 = double('Client2', handle_disconnect: nil, respond_to?: true)
|
|
433
|
+
|
|
434
|
+
shared_connection.add_client(failing_client)
|
|
435
|
+
shared_connection.add_client(client2)
|
|
436
|
+
|
|
437
|
+
# Should not raise despite one client failing
|
|
438
|
+
expect { shared_connection.send(:handle_disconnect) }.not_to raise_error
|
|
439
|
+
|
|
440
|
+
# Other clients should still be notified
|
|
441
|
+
expect(client2).to have_received(:handle_disconnect)
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
describe 'handle_error' do
|
|
446
|
+
it 'broadcasts error to all clients' do
|
|
447
|
+
client2 = double('Client2', handle_error: nil, respond_to?: true)
|
|
448
|
+
shared_connection.add_client(client2)
|
|
449
|
+
|
|
450
|
+
error = StandardError.new('Test error')
|
|
451
|
+
|
|
452
|
+
expect(mock_client).to receive(:handle_error).with(error)
|
|
453
|
+
expect(client2).to receive(:handle_error).with(error)
|
|
454
|
+
|
|
455
|
+
shared_connection.send(:handle_error, error)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
it 'handles nested errors gracefully' do
|
|
459
|
+
failing_client = double('FailingClient', respond_to?: true)
|
|
460
|
+
allow(failing_client).to receive(:handle_error).and_raise('Nested error')
|
|
461
|
+
|
|
462
|
+
shared_connection.add_client(failing_client)
|
|
463
|
+
|
|
464
|
+
error = StandardError.new('Original error')
|
|
465
|
+
|
|
466
|
+
expect { shared_connection.send(:handle_error, error) }.not_to raise_error
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
describe '#set_debug' do
|
|
472
|
+
it 'updates debug flag' do
|
|
473
|
+
shared_connection.set_debug(true)
|
|
474
|
+
|
|
475
|
+
expect(shared_connection.instance_variable_get(:@debug)).to be true
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
it 'propagates debug to existing connection' do
|
|
479
|
+
connection = double('SSEConnection', connect: nil, disconnect: nil)
|
|
480
|
+
options = { debug: false }
|
|
481
|
+
connection.instance_variable_set(:@options, options)
|
|
482
|
+
|
|
483
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
484
|
+
|
|
485
|
+
shared_connection.set_debug(true)
|
|
486
|
+
|
|
487
|
+
updated_options = connection.instance_variable_get(:@options)
|
|
488
|
+
expect(updated_options[:debug]).to be true
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
it 'does not raise error when connection is nil' do
|
|
492
|
+
shared_connection.instance_variable_set(:@connection, nil)
|
|
493
|
+
|
|
494
|
+
expect { shared_connection.set_debug(true) }.not_to raise_error
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
describe 'cleanup' do
|
|
499
|
+
it 'disconnects connection' do
|
|
500
|
+
connection = double('SSEConnection', connect: nil, disconnect: nil)
|
|
501
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
502
|
+
|
|
503
|
+
expect(connection).to receive(:disconnect)
|
|
504
|
+
shared_connection.send(:cleanup)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
it 'clears connection reference' do
|
|
508
|
+
connection = double('SSEConnection', connect: nil, disconnect: nil)
|
|
509
|
+
shared_connection.instance_variable_set(:@connection, connection)
|
|
510
|
+
|
|
511
|
+
shared_connection.send(:cleanup)
|
|
512
|
+
|
|
513
|
+
expect(shared_connection.instance_variable_get(:@connection)).to be_nil
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
it 'clears cached payload' do
|
|
517
|
+
shared_connection.instance_variable_set(:@last_payload, { flags: {} })
|
|
518
|
+
|
|
519
|
+
shared_connection.send(:cleanup)
|
|
520
|
+
|
|
521
|
+
expect(shared_connection.instance_variable_get(:@last_payload)).to be_nil
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
it 'resets is_connecting flag' do
|
|
525
|
+
shared_connection.instance_variable_get(:@is_connecting).make_true
|
|
526
|
+
|
|
527
|
+
shared_connection.send(:cleanup)
|
|
528
|
+
|
|
529
|
+
expect(shared_connection.instance_variable_get(:@is_connecting).value).to be false
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
it 'clears connection promise' do
|
|
533
|
+
shared_connection.instance_variable_set(:@connection_promise, 'promise')
|
|
534
|
+
|
|
535
|
+
shared_connection.send(:cleanup)
|
|
536
|
+
|
|
537
|
+
expect(shared_connection.instance_variable_get(:@connection_promise)).to be_nil
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
describe 'thread safety' do
|
|
542
|
+
it 'handles concurrent client additions' do
|
|
543
|
+
clients = 20.times.map { |i| double("Client#{i}") }
|
|
544
|
+
|
|
545
|
+
threads = clients.map do |client|
|
|
546
|
+
Thread.new { shared_connection.add_client(client) }
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
threads.each(&:join)
|
|
550
|
+
|
|
551
|
+
expect(shared_connection.client_count).to eq(20)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
it 'handles concurrent client removals' do
|
|
555
|
+
clients = 10.times.map { |i| double("Client#{i}") }
|
|
556
|
+
clients.each { |client| shared_connection.add_client(client) }
|
|
557
|
+
|
|
558
|
+
threads = clients.map do |client|
|
|
559
|
+
Thread.new { shared_connection.remove_client(client) }
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
threads.each(&:join)
|
|
563
|
+
|
|
564
|
+
expect(shared_connection.client_count).to eq(0)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
it 'handles concurrent message broadcasts' do
|
|
568
|
+
call_count = Concurrent::AtomicFixnum.new(0)
|
|
569
|
+
client = double('Client', respond_to?: true)
|
|
570
|
+
allow(client).to receive(:handle_flags_update) { call_count.increment }
|
|
571
|
+
|
|
572
|
+
shared_connection.add_client(client)
|
|
573
|
+
|
|
574
|
+
threads = 10.times.map do
|
|
575
|
+
Thread.new do
|
|
576
|
+
shared_connection.send(:handle_message, { flags: {} })
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
threads.each(&:join)
|
|
581
|
+
|
|
582
|
+
expect(call_count.value).to eq(10)
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end
|