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.
@@ -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