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,691 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe ToggleCraft::SSEConnection do
|
|
4
|
+
let(:url) { 'https://test.togglecraft.io' }
|
|
5
|
+
let(:sdk_key) { 'test-sdk-key-123' }
|
|
6
|
+
let(:connection) do
|
|
7
|
+
described_class.new(
|
|
8
|
+
url: url,
|
|
9
|
+
sdk_key: sdk_key,
|
|
10
|
+
debug: false,
|
|
11
|
+
reconnect_interval: 0.1,
|
|
12
|
+
max_reconnect_interval: 0.5,
|
|
13
|
+
heartbeat_interval: 1
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
after do
|
|
18
|
+
connection.disconnect
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe '#initialize' do
|
|
22
|
+
it 'initializes with required parameters' do
|
|
23
|
+
expect(connection.url).to eq(url)
|
|
24
|
+
expect(connection.sdk_key).to eq(sdk_key)
|
|
25
|
+
expect(connection.connected).to be false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'removes /cable suffix from URL' do
|
|
29
|
+
conn = described_class.new(url: 'https://test.io/cable', sdk_key: 'key')
|
|
30
|
+
expect(conn.url).to eq('https://test.io')
|
|
31
|
+
conn.disconnect
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'sets default options' do
|
|
35
|
+
conn = described_class.new(url: url, sdk_key: sdk_key)
|
|
36
|
+
expect(conn.instance_variable_get(:@options)[:reconnect_interval]).to eq(1)
|
|
37
|
+
expect(conn.instance_variable_get(:@options)[:heartbeat_interval]).to eq(300)
|
|
38
|
+
conn.disconnect
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#connected?' do
|
|
43
|
+
it 'returns false when not connected' do
|
|
44
|
+
expect(connection.connected?).to be false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'returns true when connected' do
|
|
48
|
+
connection.instance_variable_set(:@connected, true)
|
|
49
|
+
expect(connection.connected?).to be true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe '#disconnect' do
|
|
54
|
+
it 'sets connected to false' do
|
|
55
|
+
connection.instance_variable_set(:@connected, true)
|
|
56
|
+
connection.disconnect
|
|
57
|
+
expect(connection.connected?).to be false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'clears connection_id' do
|
|
61
|
+
connection.instance_variable_set(:@connection_id, 'test-id')
|
|
62
|
+
connection.disconnect
|
|
63
|
+
expect(connection.connection_id).to be_nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'stops heartbeat' do
|
|
67
|
+
heartbeat_timer = Thread.new { sleep }
|
|
68
|
+
connection.instance_variable_set(:@heartbeat_timer, heartbeat_timer)
|
|
69
|
+
|
|
70
|
+
connection.disconnect
|
|
71
|
+
sleep 0.1
|
|
72
|
+
|
|
73
|
+
expect(heartbeat_timer.alive?).to be false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'sets should_reconnect to false' do
|
|
77
|
+
connection.disconnect
|
|
78
|
+
expect(connection.instance_variable_get(:@should_reconnect)).to be false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe 'SSE message processing' do
|
|
83
|
+
it 'processes SSE messages with data' do
|
|
84
|
+
messages = []
|
|
85
|
+
conn = described_class.new(
|
|
86
|
+
url: url,
|
|
87
|
+
sdk_key: sdk_key,
|
|
88
|
+
on_message: ->(msg) { messages << msg }
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
message = "data: {\"test\":\"value\"}\n\n"
|
|
92
|
+
conn.send(:process_sse_message, message.chomp("\n\n"))
|
|
93
|
+
|
|
94
|
+
expect(messages.length).to eq(1)
|
|
95
|
+
expect(messages.first[:test]).to eq('value')
|
|
96
|
+
|
|
97
|
+
conn.disconnect
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'extracts connection_id from messages' do
|
|
101
|
+
conn = described_class.new(url: url, sdk_key: sdk_key)
|
|
102
|
+
|
|
103
|
+
message = 'data: {"connection_id":"conn-123"}'
|
|
104
|
+
conn.send(:process_sse_message, message)
|
|
105
|
+
|
|
106
|
+
expect(conn.connection_id).to eq('conn-123')
|
|
107
|
+
|
|
108
|
+
conn.disconnect
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'handles event type and data' do
|
|
112
|
+
messages = []
|
|
113
|
+
conn = described_class.new(
|
|
114
|
+
url: url,
|
|
115
|
+
sdk_key: sdk_key,
|
|
116
|
+
on_message: ->(msg) { messages << msg }
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
message = "event: update\ndata: {\"type\":\"version_update\",\"version\":1}"
|
|
120
|
+
conn.send(:process_sse_message, message)
|
|
121
|
+
|
|
122
|
+
expect(messages.length).to eq(1)
|
|
123
|
+
expect(messages.first[:type]).to eq('version_update')
|
|
124
|
+
|
|
125
|
+
conn.disconnect
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'ignores messages without data' do
|
|
129
|
+
messages = []
|
|
130
|
+
conn = described_class.new(
|
|
131
|
+
url: url,
|
|
132
|
+
sdk_key: sdk_key,
|
|
133
|
+
on_message: ->(msg) { messages << msg }
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
conn.send(:process_sse_message, 'event: ping')
|
|
137
|
+
expect(messages).to be_empty
|
|
138
|
+
|
|
139
|
+
conn.disconnect
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'handles invalid JSON gracefully' do
|
|
143
|
+
messages = []
|
|
144
|
+
conn = described_class.new(
|
|
145
|
+
url: url,
|
|
146
|
+
sdk_key: sdk_key,
|
|
147
|
+
on_message: ->(msg) { messages << msg }
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
expect do
|
|
151
|
+
conn.send(:process_sse_message, 'data: {invalid json}')
|
|
152
|
+
end.not_to raise_error
|
|
153
|
+
|
|
154
|
+
expect(messages).to be_empty
|
|
155
|
+
|
|
156
|
+
conn.disconnect
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
describe 'heartbeat mechanism' do
|
|
161
|
+
it 'sends heartbeat with correct headers' do
|
|
162
|
+
stub_request(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
163
|
+
.with(
|
|
164
|
+
headers: {
|
|
165
|
+
'X-SDK-Key' => sdk_key,
|
|
166
|
+
'X-Connection-ID' => 'test-conn-id'
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
.to_return(status: 200, body: '{"status":"ok"}')
|
|
170
|
+
|
|
171
|
+
connection.instance_variable_set(:@connected, true)
|
|
172
|
+
connection.instance_variable_set(:@connection_id, 'test-conn-id')
|
|
173
|
+
|
|
174
|
+
connection.send(:send_heartbeat)
|
|
175
|
+
|
|
176
|
+
expect(WebMock).to have_requested(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'skips heartbeat when not connected' do
|
|
180
|
+
connection.instance_variable_set(:@connected, false)
|
|
181
|
+
|
|
182
|
+
expect(HTTP).not_to receive(:timeout)
|
|
183
|
+
connection.send(:send_heartbeat)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'skips heartbeat when connection_id is nil' do
|
|
187
|
+
connection.instance_variable_set(:@connected, true)
|
|
188
|
+
connection.instance_variable_set(:@connection_id, nil)
|
|
189
|
+
|
|
190
|
+
expect(HTTP).not_to receive(:timeout)
|
|
191
|
+
connection.send(:send_heartbeat)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it 'handles 404 response by reconnecting' do
|
|
195
|
+
stub_request(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
196
|
+
.to_return(status: 404, body: '{"error":"not_found"}')
|
|
197
|
+
|
|
198
|
+
connection.instance_variable_set(:@connected, true)
|
|
199
|
+
connection.instance_variable_set(:@connection_id, 'test-id')
|
|
200
|
+
|
|
201
|
+
connection.send(:send_heartbeat)
|
|
202
|
+
|
|
203
|
+
# Should disconnect
|
|
204
|
+
expect(connection.connected?).to be false
|
|
205
|
+
expect(connection.instance_variable_get(:@should_reconnect)).to be true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'handles heartbeat errors gracefully' do
|
|
209
|
+
stub_request(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
210
|
+
.to_raise(StandardError.new('Network error'))
|
|
211
|
+
|
|
212
|
+
connection.instance_variable_set(:@connected, true)
|
|
213
|
+
connection.instance_variable_set(:@connection_id, 'test-id')
|
|
214
|
+
|
|
215
|
+
expect do
|
|
216
|
+
connection.send(:send_heartbeat)
|
|
217
|
+
end.not_to raise_error
|
|
218
|
+
|
|
219
|
+
# Should still be connected (don't disconnect on heartbeat failure)
|
|
220
|
+
expect(connection.connected?).to be true
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
describe 'reconnection strategy' do
|
|
225
|
+
it 'uses exponential backoff for fast reconnection' do
|
|
226
|
+
connection.instance_variable_set(:@reconnect_attempts, 0)
|
|
227
|
+
connection.instance_variable_set(:@should_reconnect, true)
|
|
228
|
+
|
|
229
|
+
# Mock connect to prevent actual connection
|
|
230
|
+
allow(connection).to receive(:connect)
|
|
231
|
+
|
|
232
|
+
# Schedule reconnection
|
|
233
|
+
connection.send(:schedule_reconnection)
|
|
234
|
+
|
|
235
|
+
# Reconnect attempts should increment
|
|
236
|
+
expect(connection.instance_variable_get(:@reconnect_attempts)).to eq(1)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it 'switches to slow mode after max attempts' do
|
|
240
|
+
connection.instance_variable_set(:@reconnect_attempts, 10) # Max attempts
|
|
241
|
+
connection.instance_variable_set(:@should_reconnect, true)
|
|
242
|
+
|
|
243
|
+
allow(connection).to receive(:connect)
|
|
244
|
+
|
|
245
|
+
connection.send(:schedule_reconnection)
|
|
246
|
+
|
|
247
|
+
# Should stay at max attempts in slow mode
|
|
248
|
+
expect(connection.instance_variable_get(:@reconnect_attempts)).to eq(10)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it 'does not reconnect when should_reconnect is false' do
|
|
252
|
+
connection.instance_variable_set(:@should_reconnect, false)
|
|
253
|
+
|
|
254
|
+
expect(connection).not_to receive(:connect)
|
|
255
|
+
connection.send(:schedule_reconnection)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
describe 'callbacks' do
|
|
260
|
+
it 'calls on_connect callback' do
|
|
261
|
+
called = false
|
|
262
|
+
conn = described_class.new(
|
|
263
|
+
url: url,
|
|
264
|
+
sdk_key: sdk_key,
|
|
265
|
+
on_connect: -> { called = true }
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
conn.instance_variable_get(:@on_connect).call
|
|
269
|
+
expect(called).to be true
|
|
270
|
+
|
|
271
|
+
conn.disconnect
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it 'calls on_disconnect callback' do
|
|
275
|
+
called = false
|
|
276
|
+
conn = described_class.new(
|
|
277
|
+
url: url,
|
|
278
|
+
sdk_key: sdk_key,
|
|
279
|
+
on_disconnect: -> { called = true }
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
conn.instance_variable_set(:@connected, true)
|
|
283
|
+
conn.send(:handle_disconnection)
|
|
284
|
+
|
|
285
|
+
expect(called).to be true
|
|
286
|
+
|
|
287
|
+
conn.disconnect
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it 'calls on_error callback' do
|
|
291
|
+
error = nil
|
|
292
|
+
conn = described_class.new(
|
|
293
|
+
url: url,
|
|
294
|
+
sdk_key: sdk_key,
|
|
295
|
+
on_error: ->(e) { error = e }
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
test_error = StandardError.new('Test error')
|
|
299
|
+
conn.send(:handle_error, test_error)
|
|
300
|
+
|
|
301
|
+
expect(error).to eq(test_error)
|
|
302
|
+
|
|
303
|
+
conn.disconnect
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it 'calls on_message callback' do
|
|
307
|
+
messages = []
|
|
308
|
+
conn = described_class.new(
|
|
309
|
+
url: url,
|
|
310
|
+
sdk_key: sdk_key,
|
|
311
|
+
on_message: ->(msg) { messages << msg }
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
conn.instance_variable_get(:@on_message).call({ test: 'data' })
|
|
315
|
+
expect(messages).to eq([{ test: 'data' }])
|
|
316
|
+
|
|
317
|
+
conn.disconnect
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
describe 'thread management' do
|
|
322
|
+
it 'creates connection thread on connect', skip: 'Complex integration test' do
|
|
323
|
+
# This would require mocking HTTP.get streaming behavior
|
|
324
|
+
# Skipping for unit tests - would be covered in integration tests
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
it 'stops threads on disconnect' do
|
|
328
|
+
connection_thread = Thread.new { sleep 10 }
|
|
329
|
+
heartbeat_timer = Thread.new { sleep 10 }
|
|
330
|
+
|
|
331
|
+
connection.instance_variable_set(:@connection_thread, connection_thread)
|
|
332
|
+
connection.instance_variable_set(:@heartbeat_timer, heartbeat_timer)
|
|
333
|
+
|
|
334
|
+
connection.disconnect
|
|
335
|
+
sleep 0.1
|
|
336
|
+
|
|
337
|
+
expect(connection_thread.alive?).to be false
|
|
338
|
+
expect(heartbeat_timer.alive?).to be false
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
describe '#reconnect' do
|
|
343
|
+
it 'disconnects and resets state' do
|
|
344
|
+
connection.instance_variable_set(:@connected, true)
|
|
345
|
+
connection.instance_variable_set(:@reconnect_attempts, 5)
|
|
346
|
+
allow(connection).to receive(:start_connection) # Prevent actual connection
|
|
347
|
+
|
|
348
|
+
connection.reconnect
|
|
349
|
+
|
|
350
|
+
expect(connection.connected?).to be false
|
|
351
|
+
expect(connection.instance_variable_get(:@should_reconnect)).to be true
|
|
352
|
+
expect(connection.instance_variable_get(:@reconnect_attempts)).to eq(0)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it 'calls connect after disconnect' do
|
|
356
|
+
allow(connection).to receive(:start_connection)
|
|
357
|
+
expect(connection).to receive(:start_connection)
|
|
358
|
+
|
|
359
|
+
connection.reconnect
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
describe 'zombie connection prevention (CRITICAL)' do
|
|
364
|
+
context 'heartbeat behavior when SSE connection drops' do
|
|
365
|
+
it 'MUST NOT send heartbeat when @connected is false' do
|
|
366
|
+
connection.instance_variable_set(:@connected, false)
|
|
367
|
+
connection.instance_variable_set(:@connection_id, 'valid-id')
|
|
368
|
+
|
|
369
|
+
# Should not make any HTTP request
|
|
370
|
+
expect(HTTP).not_to receive(:timeout)
|
|
371
|
+
|
|
372
|
+
connection.send(:send_heartbeat)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it 'MUST NOT send heartbeat when @connected is nil' do
|
|
376
|
+
connection.instance_variable_set(:@connected, nil)
|
|
377
|
+
connection.instance_variable_set(:@connection_id, 'valid-id')
|
|
378
|
+
|
|
379
|
+
expect(HTTP).not_to receive(:timeout)
|
|
380
|
+
|
|
381
|
+
connection.send(:send_heartbeat)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it 'MUST NOT send heartbeat when connection_id is missing even if connected' do
|
|
385
|
+
connection.instance_variable_set(:@connected, true)
|
|
386
|
+
connection.instance_variable_set(:@connection_id, nil)
|
|
387
|
+
|
|
388
|
+
expect(HTTP).not_to receive(:timeout)
|
|
389
|
+
|
|
390
|
+
connection.send(:send_heartbeat)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
it 'MUST NOT send heartbeat when connection_id is empty string' do
|
|
394
|
+
connection.instance_variable_set(:@connected, true)
|
|
395
|
+
connection.instance_variable_set(:@connection_id, '')
|
|
396
|
+
|
|
397
|
+
# Empty string is truthy in Ruby but will fail the unless check
|
|
398
|
+
# The implementation checks: unless @connection_id
|
|
399
|
+
# Empty string passes this check, so heartbeat will be sent
|
|
400
|
+
# This test documents current behavior - empty string is treated as valid
|
|
401
|
+
stub_request(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
402
|
+
.to_return(status: 200, body: '{"status":"ok"}')
|
|
403
|
+
|
|
404
|
+
expect { connection.send(:send_heartbeat) }.not_to raise_error
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it 'only sends heartbeat when BOTH connected AND connection_id are present' do
|
|
408
|
+
stub_request(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
409
|
+
.to_return(status: 200, body: '{"status":"ok"}')
|
|
410
|
+
|
|
411
|
+
connection.instance_variable_set(:@connected, true)
|
|
412
|
+
connection.instance_variable_set(:@connection_id, 'valid-id')
|
|
413
|
+
|
|
414
|
+
connection.send(:send_heartbeat)
|
|
415
|
+
|
|
416
|
+
expect(WebMock).to have_requested(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
context 'connection state transitions' do
|
|
421
|
+
it 'sets @connected to false immediately on disconnect' do
|
|
422
|
+
connection.instance_variable_set(:@connected, true)
|
|
423
|
+
|
|
424
|
+
connection.disconnect
|
|
425
|
+
|
|
426
|
+
expect(connection.connected?).to be false
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
it 'clears connection_id on disconnect' do
|
|
430
|
+
connection.instance_variable_set(:@connection_id, 'test-id')
|
|
431
|
+
|
|
432
|
+
connection.disconnect
|
|
433
|
+
|
|
434
|
+
expect(connection.connection_id).to be_nil
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
it 'stops heartbeat thread on disconnect' do
|
|
438
|
+
heartbeat_timer = Thread.new { sleep 10 }
|
|
439
|
+
connection.instance_variable_set(:@heartbeat_timer, heartbeat_timer)
|
|
440
|
+
|
|
441
|
+
connection.disconnect
|
|
442
|
+
sleep 0.1
|
|
443
|
+
|
|
444
|
+
expect(heartbeat_timer.alive?).to be false
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
it 'prevents zombie connections when server crashes' do
|
|
448
|
+
# Simulate: SSE connection drops, AnyCable crashes, heartbeat timer still running
|
|
449
|
+
connection.instance_variable_set(:@connected, true)
|
|
450
|
+
connection.instance_variable_set(:@connection_id, 'conn-123')
|
|
451
|
+
|
|
452
|
+
# Start heartbeat timer
|
|
453
|
+
connection.send(:start_heartbeat)
|
|
454
|
+
|
|
455
|
+
# Simulate SSE connection drop (AnyCable crash)
|
|
456
|
+
connection.send(:handle_disconnection)
|
|
457
|
+
|
|
458
|
+
# Heartbeat should NOT be sent after disconnection
|
|
459
|
+
expect(connection.connected?).to be false
|
|
460
|
+
expect(HTTP).not_to receive(:timeout)
|
|
461
|
+
|
|
462
|
+
# Attempt to send heartbeat
|
|
463
|
+
connection.send(:send_heartbeat)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
context 'heartbeat timing and jitter' do
|
|
468
|
+
it 'applies 0-30 second jitter to heartbeat interval' do
|
|
469
|
+
connection.instance_variable_set(:@connected, true)
|
|
470
|
+
connection.instance_variable_set(:@connection_id, 'test-id')
|
|
471
|
+
|
|
472
|
+
# Start heartbeat should not crash
|
|
473
|
+
expect { connection.send(:start_heartbeat) }.not_to raise_error
|
|
474
|
+
|
|
475
|
+
# Clean up
|
|
476
|
+
connection.send(:stop_heartbeat)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
it 'uses configured heartbeat_interval' do
|
|
480
|
+
custom_conn = described_class.new(
|
|
481
|
+
url: url,
|
|
482
|
+
sdk_key: sdk_key,
|
|
483
|
+
heartbeat_interval: 120 # 2 minutes
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
options = custom_conn.instance_variable_get(:@options)
|
|
487
|
+
expect(options[:heartbeat_interval]).to eq(120)
|
|
488
|
+
|
|
489
|
+
custom_conn.disconnect
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
it 'starts heartbeat after successful SSE connection' do
|
|
493
|
+
# The heartbeat is started in connect_sse after connection is established
|
|
494
|
+
# This test verifies heartbeat can be started without errors
|
|
495
|
+
connection.instance_variable_set(:@connected, true)
|
|
496
|
+
connection.instance_variable_set(:@connection_id, 'test-id')
|
|
497
|
+
|
|
498
|
+
expect { connection.send(:start_heartbeat) }.not_to raise_error
|
|
499
|
+
|
|
500
|
+
# Verify heartbeat timer is running
|
|
501
|
+
timer = connection.instance_variable_get(:@heartbeat_timer)
|
|
502
|
+
expect(timer).to be_a(Thread)
|
|
503
|
+
expect(timer.alive?).to be true
|
|
504
|
+
|
|
505
|
+
connection.send(:stop_heartbeat)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
context 'network failure scenarios' do
|
|
510
|
+
it 'does not disconnect on heartbeat network error' do
|
|
511
|
+
stub_request(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
512
|
+
.to_timeout
|
|
513
|
+
|
|
514
|
+
connection.instance_variable_set(:@connected, true)
|
|
515
|
+
connection.instance_variable_set(:@connection_id, 'test-id')
|
|
516
|
+
|
|
517
|
+
connection.send(:send_heartbeat)
|
|
518
|
+
|
|
519
|
+
# Should remain connected despite network error
|
|
520
|
+
expect(connection.connected?).to be true
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
it 'logs heartbeat errors when debug enabled' do
|
|
524
|
+
debug_conn = described_class.new(url: url, sdk_key: sdk_key, debug: true)
|
|
525
|
+
|
|
526
|
+
stub_request(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
527
|
+
.to_raise(StandardError.new('Network timeout'))
|
|
528
|
+
|
|
529
|
+
debug_conn.instance_variable_set(:@connected, true)
|
|
530
|
+
debug_conn.instance_variable_set(:@connection_id, 'test-id')
|
|
531
|
+
|
|
532
|
+
expect do
|
|
533
|
+
debug_conn.send(:send_heartbeat)
|
|
534
|
+
end.to output(/Heartbeat request failed/).to_stdout
|
|
535
|
+
|
|
536
|
+
debug_conn.disconnect
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
it 'handles 500 server errors without disconnecting' do
|
|
540
|
+
stub_request(:post, 'https://togglecraft.io/api/v1/heartbeat')
|
|
541
|
+
.to_return(status: 500, body: 'Internal Server Error')
|
|
542
|
+
|
|
543
|
+
connection.instance_variable_set(:@connected, true)
|
|
544
|
+
connection.instance_variable_set(:@connection_id, 'test-id')
|
|
545
|
+
|
|
546
|
+
connection.send(:send_heartbeat)
|
|
547
|
+
|
|
548
|
+
# Should log error but remain connected
|
|
549
|
+
expect(connection.connected?).to be true
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
describe 'exponential backoff edge cases' do
|
|
555
|
+
it 'calculates correct backoff intervals' do
|
|
556
|
+
connection.instance_variable_set(:@should_reconnect, true)
|
|
557
|
+
allow(connection).to receive(:connect)
|
|
558
|
+
|
|
559
|
+
# First attempt: 0.1 * 2^0 = 0.1
|
|
560
|
+
connection.instance_variable_set(:@reconnect_attempts, 0)
|
|
561
|
+
connection.send(:schedule_reconnection)
|
|
562
|
+
expect(connection.instance_variable_get(:@reconnect_attempts)).to eq(1)
|
|
563
|
+
|
|
564
|
+
# Second attempt: min(0.1 * 2^1, 0.5) = 0.2
|
|
565
|
+
connection.send(:schedule_reconnection)
|
|
566
|
+
expect(connection.instance_variable_get(:@reconnect_attempts)).to eq(2)
|
|
567
|
+
|
|
568
|
+
# Third attempt: min(0.1 * 2^2, 0.5) = 0.4
|
|
569
|
+
connection.send(:schedule_reconnection)
|
|
570
|
+
expect(connection.instance_variable_get(:@reconnect_attempts)).to eq(3)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
it 'caps backoff at max_reconnect_interval' do
|
|
574
|
+
connection.instance_variable_set(:@should_reconnect, true)
|
|
575
|
+
connection.instance_variable_set(:@reconnect_attempts, 100)
|
|
576
|
+
allow(connection).to receive(:connect)
|
|
577
|
+
|
|
578
|
+
connection.send(:schedule_reconnection)
|
|
579
|
+
|
|
580
|
+
# Should use slow_reconnect_interval after max attempts
|
|
581
|
+
expect(connection.instance_variable_get(:@reconnect_attempts)).to eq(100)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
it 'resets reconnect attempts on successful connection' do
|
|
585
|
+
connection.instance_variable_set(:@reconnect_attempts, 5)
|
|
586
|
+
|
|
587
|
+
# Simulate successful connection
|
|
588
|
+
connection.instance_variable_set(:@connected, true)
|
|
589
|
+
connection.instance_variable_get(:@on_connect).call
|
|
590
|
+
|
|
591
|
+
# Verify attempts reset (indirectly, via connected state)
|
|
592
|
+
expect(connection.connected?).to be true
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
describe 'SSE message buffering' do
|
|
597
|
+
it 'handles incomplete SSE messages' do
|
|
598
|
+
messages = []
|
|
599
|
+
conn = described_class.new(
|
|
600
|
+
url: url,
|
|
601
|
+
sdk_key: sdk_key,
|
|
602
|
+
on_message: ->(msg) { messages << msg }
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# Process incomplete message (no \n\n terminator)
|
|
606
|
+
incomplete = 'data: {"partial":'
|
|
607
|
+
|
|
608
|
+
# Should not crash
|
|
609
|
+
expect { conn.send(:process_sse_message, incomplete) }.not_to raise_error
|
|
610
|
+
|
|
611
|
+
conn.disconnect
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
it 'processes multiple messages in single chunk' do
|
|
615
|
+
messages = []
|
|
616
|
+
conn = described_class.new(
|
|
617
|
+
url: url,
|
|
618
|
+
sdk_key: sdk_key,
|
|
619
|
+
on_message: ->(msg) { messages << msg }
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# Two complete messages
|
|
623
|
+
msg1 = 'data: {"id":1}'
|
|
624
|
+
msg2 = 'data: {"id":2}'
|
|
625
|
+
|
|
626
|
+
conn.send(:process_sse_message, msg1)
|
|
627
|
+
conn.send(:process_sse_message, msg2)
|
|
628
|
+
|
|
629
|
+
expect(messages.length).to eq(2)
|
|
630
|
+
expect(messages[0][:id]).to eq(1)
|
|
631
|
+
expect(messages[1][:id]).to eq(2)
|
|
632
|
+
|
|
633
|
+
conn.disconnect
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
it 'handles messages with event type and data' do
|
|
637
|
+
messages = []
|
|
638
|
+
conn = described_class.new(
|
|
639
|
+
url: url,
|
|
640
|
+
sdk_key: sdk_key,
|
|
641
|
+
on_message: ->(msg) { messages << msg }
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
complex_message = "event: update\ndata: {\"version\":5}"
|
|
645
|
+
|
|
646
|
+
conn.send(:process_sse_message, complex_message)
|
|
647
|
+
|
|
648
|
+
expect(messages.length).to eq(1)
|
|
649
|
+
expect(messages.first[:version]).to eq(5)
|
|
650
|
+
|
|
651
|
+
conn.disconnect
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
it 'ignores empty messages' do
|
|
655
|
+
messages = []
|
|
656
|
+
conn = described_class.new(
|
|
657
|
+
url: url,
|
|
658
|
+
sdk_key: sdk_key,
|
|
659
|
+
on_message: ->(msg) { messages << msg }
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
conn.send(:process_sse_message, '')
|
|
663
|
+
conn.send(:process_sse_message, ' ')
|
|
664
|
+
conn.send(:process_sse_message, "\n")
|
|
665
|
+
|
|
666
|
+
expect(messages).to be_empty
|
|
667
|
+
|
|
668
|
+
conn.disconnect
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
describe 'URL handling' do
|
|
673
|
+
it 'removes /cable suffix from URL' do
|
|
674
|
+
conn = described_class.new(url: 'https://example.com/cable', sdk_key: 'key')
|
|
675
|
+
expect(conn.url).to eq('https://example.com')
|
|
676
|
+
conn.disconnect
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
it 'keeps URL without /cable unchanged' do
|
|
680
|
+
conn = described_class.new(url: 'https://example.com', sdk_key: 'key')
|
|
681
|
+
expect(conn.url).to eq('https://example.com')
|
|
682
|
+
conn.disconnect
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
it 'handles URL with trailing slash' do
|
|
686
|
+
conn = described_class.new(url: 'https://example.com/', sdk_key: 'key')
|
|
687
|
+
expect(conn.url).to eq('https://example.com/')
|
|
688
|
+
conn.disconnect
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|