ruby-asterisk 0.2.0 → 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.
Files changed (77) hide show
  1. checksums.yaml +5 -5
  2. data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
  3. data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
  4. data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
  5. data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
  6. data/.github/workflows/ci.yml +79 -0
  7. data/.gitignore +48 -4
  8. data/.rubocop.yml +84 -0
  9. data/CHANGELOG.md +52 -0
  10. data/Gemfile +3 -1
  11. data/README.md +410 -108
  12. data/Rakefile +3 -5
  13. data/lib/ruby-asterisk/agi/protocol.rb +160 -0
  14. data/lib/ruby-asterisk/agi/server.rb +108 -0
  15. data/lib/ruby-asterisk/agi/session.rb +187 -0
  16. data/lib/ruby-asterisk/ami/client.rb +135 -0
  17. data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
  18. data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
  19. data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
  20. data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
  21. data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
  22. data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
  23. data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
  24. data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
  25. data/lib/ruby-asterisk/ami/event.rb +22 -0
  26. data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
  27. data/lib/ruby-asterisk/ami/parser.rb +60 -0
  28. data/lib/ruby-asterisk/ami/promise.rb +108 -0
  29. data/lib/ruby-asterisk/ami/reactor.rb +162 -0
  30. data/lib/ruby-asterisk/ari/client.rb +94 -0
  31. data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
  32. data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
  33. data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
  34. data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
  35. data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
  36. data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
  37. data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
  38. data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
  39. data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
  40. data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
  41. data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
  42. data/lib/ruby-asterisk/ari/websocket.rb +155 -0
  43. data/lib/ruby-asterisk/compat.rb +7 -0
  44. data/lib/ruby-asterisk/error.rb +10 -0
  45. data/lib/ruby-asterisk/parsing_constants.rb +71 -69
  46. data/lib/ruby-asterisk/request.rb +33 -14
  47. data/lib/ruby-asterisk/response.rb +43 -16
  48. data/lib/ruby-asterisk/response_builder.rb +18 -0
  49. data/lib/ruby-asterisk/response_parser.rb +10 -9
  50. data/lib/ruby-asterisk/version.rb +4 -2
  51. data/lib/ruby-asterisk.rb +5 -222
  52. data/ruby-asterisk.gemspec +23 -17
  53. data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
  54. data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
  55. data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
  56. data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
  57. data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
  58. data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
  59. data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
  60. data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
  61. data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
  62. data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
  63. data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
  64. data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
  65. data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
  66. data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
  67. data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
  68. data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
  69. data/spec/ruby-asterisk/immutability_spec.rb +148 -0
  70. data/spec/ruby-asterisk/request_spec.rb +29 -9
  71. data/spec/ruby-asterisk/response_spec.rb +46 -47
  72. data/spec/spec_helper.rb +12 -0
  73. data/spec/support/mock_ami_server.rb +50 -0
  74. data/spec/support/mock_ari_websocket_server.rb +114 -0
  75. metadata +138 -18
  76. data/CHANGELOG +0 -3
  77. data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -192
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/ari/client'
4
+
5
+ RSpec.describe RubyAsterisk::ARI::Resources::Endpoint do
6
+ let(:client) { instance_double(RubyAsterisk::ARI::Client) }
7
+ let(:data) do
8
+ { 'technology' => 'SIP', 'resource' => 'alice', 'state' => 'online' }
9
+ end
10
+
11
+ subject(:endpoint) { described_class.new(data, client) }
12
+
13
+ describe '#technology' do
14
+ it 'returns the technology from data' do
15
+ expect(endpoint.technology).to eq('SIP')
16
+ end
17
+ end
18
+
19
+ describe '#resource' do
20
+ it 'returns the resource from data' do
21
+ expect(endpoint.resource).to eq('alice')
22
+ end
23
+ end
24
+
25
+ describe '#state' do
26
+ it 'returns the state from data' do
27
+ expect(endpoint.state).to eq('online')
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/ari/client'
4
+
5
+ RSpec.describe RubyAsterisk::ARI::Resources::Playback do
6
+ let(:client) { instance_double(RubyAsterisk::ARI::Client) }
7
+ let(:data) { { 'id' => 'playback-789' } }
8
+
9
+ subject(:playback) { described_class.new(data, client) }
10
+
11
+ describe '#id' do
12
+ it 'returns the playback id from data' do
13
+ expect(playback.id).to eq('playback-789')
14
+ end
15
+ end
16
+
17
+ describe '#stop' do
18
+ it 'deletes the playback' do
19
+ allow(client).to receive(:delete).with('/ari/playbacks/playback-789').and_return(nil)
20
+ playback.stop
21
+ expect(client).to have_received(:delete).with('/ari/playbacks/playback-789')
22
+ end
23
+ end
24
+
25
+ describe '#control' do
26
+ it 'posts the operation to the control endpoint' do
27
+ body = { operation: 'pause' }
28
+ allow(client).to receive(:post).with('/ari/playbacks/playback-789/control', body).and_return(nil)
29
+ playback.control('pause')
30
+ expect(client).to have_received(:post).with('/ari/playbacks/playback-789/control', body)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/mock_ari_websocket_server'
5
+
6
+ RSpec.describe RubyAsterisk::ARI::WebSocket, 'integration' do
7
+ let(:server) { MockAriWebSocketServer.new }
8
+ let(:base_url) { "http://127.0.0.1:#{server.port}" }
9
+ let(:logger) { Logger.new(File::NULL) }
10
+
11
+ after do
12
+ @websocket&.disconnect
13
+ server.stop
14
+ end
15
+
16
+ def connect(options = {})
17
+ opened = Queue.new
18
+ defaults = { logger: logger, reconnect_delay: 0.1 }
19
+ @websocket = described_class.new(base_url, 'api_key', 'test_app', defaults.merge(options))
20
+ yield @websocket if block_given?
21
+ @websocket.connect { opened << true }
22
+ MockAriWebSocketServer.pop(opened)
23
+ @websocket
24
+ end
25
+
26
+ it 'completes the handshake and fires the on_connect callback' do
27
+ websocket = connect
28
+
29
+ expect(websocket.connected?).to be(true)
30
+ end
31
+
32
+ it 'dispatches server events to registered callbacks' do
33
+ received = Queue.new
34
+ connect { |ws| ws.on('StasisStart') { |data| received << data } }
35
+
36
+ server.broadcast('type' => 'StasisStart', 'channel' => { 'id' => '42' })
37
+
38
+ event = MockAriWebSocketServer.pop(received)
39
+ expect(event).to eq('type' => 'StasisStart', 'channel' => { 'id' => '42' })
40
+ end
41
+
42
+ it 'sends messages to the server' do
43
+ websocket = connect
44
+
45
+ expect(websocket.send_message?({ 'type' => 'ping' })).to be(true)
46
+ expect(MockAriWebSocketServer.pop(server.messages)).to eq('{"type":"ping"}')
47
+ end
48
+
49
+ it 'sends pings at the configured interval' do
50
+ connect(ping_interval: 0.1)
51
+
52
+ expect(MockAriWebSocketServer.pop(server.pings)).not_to be_nil
53
+ end
54
+
55
+ it 'reconnects automatically after a connection drop' do
56
+ received = Queue.new
57
+ connect { |ws| ws.on('StasisStart') { |data| received << data } }
58
+ MockAriWebSocketServer.pop(server.connections) # first handshake
59
+
60
+ server.drop_clients
61
+ MockAriWebSocketServer.pop(server.connections) # second handshake
62
+
63
+ server.broadcast('type' => 'StasisStart')
64
+ expect(MockAriWebSocketServer.pop(received)).to eq('type' => 'StasisStart')
65
+ end
66
+
67
+ it 'does not reconnect when auto_reconnect is disabled' do
68
+ websocket = connect(auto_reconnect: false)
69
+ thread = websocket.instance_variable_get(:@connection_thread)
70
+
71
+ server.drop_clients
72
+
73
+ expect(thread.join(2)).to eq(thread)
74
+ expect(websocket.connected?).to be(false)
75
+ end
76
+
77
+ it 'stops cleanly on disconnect' do
78
+ websocket = connect
79
+ thread = websocket.instance_variable_get(:@connection_thread)
80
+
81
+ websocket.disconnect
82
+
83
+ expect(websocket.connected?).to be(false)
84
+ expect(thread.alive?).to be(false)
85
+ end
86
+ end
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyAsterisk::ARI::WebSocket do
6
+ let(:base_url) { 'http://localhost:8088' }
7
+ let(:api_key) { 'asterisk_api_key' }
8
+ let(:app_name) { 'stasis_app' }
9
+ let(:logger) { instance_double(Logger, info: nil, warn: nil, error: nil, debug: nil) }
10
+
11
+ subject(:websocket) { described_class.new(base_url, api_key, app_name, logger: logger) }
12
+
13
+ describe '#initialize' do
14
+ it 'sets the base_url' do
15
+ expect(websocket.instance_variable_get(:@base_url)).to eq(base_url)
16
+ end
17
+
18
+ it 'sets the app_name' do
19
+ expect(websocket.app_name).to eq(app_name)
20
+ end
21
+
22
+ it 'initializes callbacks as empty hash' do
23
+ expect(websocket.callbacks).to eq({})
24
+ end
25
+
26
+ it 'sets connected to false' do
27
+ expect(websocket.connected).to be(false)
28
+ end
29
+
30
+ it 'sets auto_reconnect to true by default' do
31
+ expect(websocket.instance_variable_get(:@should_reconnect)).to be(true)
32
+ end
33
+
34
+ it 'allows disabling auto_reconnect' do
35
+ ws = described_class.new(base_url, api_key, app_name, auto_reconnect: false)
36
+ expect(ws.instance_variable_get(:@should_reconnect)).to be(false)
37
+ end
38
+
39
+ it 'uses default ping interval' do
40
+ expect(websocket.instance_variable_get(:@ping_interval)).to eq(described_class::PING_INTERVAL)
41
+ end
42
+
43
+ it 'allows custom ping interval' do
44
+ ws = described_class.new(base_url, api_key, app_name, ping_interval: 60)
45
+ expect(ws.instance_variable_get(:@ping_interval)).to eq(60)
46
+ end
47
+ end
48
+
49
+ describe '#on' do
50
+ it 'registers a callback for an event type' do
51
+ callback = proc { |data| puts data }
52
+ websocket.on('StasisStart', &callback)
53
+
54
+ expect(websocket.callbacks['StasisStart']).to include(callback)
55
+ end
56
+
57
+ it 'allows multiple callbacks for the same event' do
58
+ callback1 = proc { |data| puts data }
59
+ callback2 = proc { |data| puts data }
60
+
61
+ websocket.on('StasisStart', &callback1)
62
+ websocket.on('StasisStart', &callback2)
63
+
64
+ expect(websocket.callbacks['StasisStart']).to include(callback1, callback2)
65
+ end
66
+
67
+ it 'converts symbol event types to strings' do
68
+ callback = proc { |data| puts data }
69
+ websocket.on(:StasisStart, &callback)
70
+
71
+ expect(websocket.callbacks['StasisStart']).to include(callback)
72
+ end
73
+
74
+ it 'returns self for method chaining' do
75
+ result = websocket.on('StasisStart') { |data| puts data }
76
+ expect(result).to eq(websocket)
77
+ end
78
+ end
79
+
80
+ describe '#connected?' do
81
+ context 'when not connected' do
82
+ it 'returns false' do
83
+ expect(websocket.connected?).to be(false)
84
+ end
85
+ end
86
+
87
+ context 'when connected flag is true but no driver' do
88
+ before do
89
+ websocket.instance_variable_set(:@connected, true)
90
+ end
91
+
92
+ it 'returns false' do
93
+ expect(websocket.connected?).to be(false)
94
+ end
95
+ end
96
+
97
+ context 'when connected with an open driver' do
98
+ let(:driver) { instance_double(WebSocket::Driver::Client, state: :open) }
99
+
100
+ before do
101
+ websocket.instance_variable_set(:@connected, true)
102
+ websocket.instance_variable_set(:@driver, driver)
103
+ end
104
+
105
+ it 'returns true' do
106
+ expect(websocket.connected?).to be(true)
107
+ end
108
+ end
109
+ end
110
+
111
+ describe '#send_message?' do
112
+ context 'when not connected' do
113
+ it 'returns false' do
114
+ expect(websocket.send_message?('test')).to be(false)
115
+ end
116
+ end
117
+
118
+ context 'when connected' do
119
+ let(:driver) { instance_double(WebSocket::Driver::Client, state: :open, text: true) }
120
+
121
+ before do
122
+ websocket.instance_variable_set(:@connected, true)
123
+ websocket.instance_variable_set(:@driver, driver)
124
+ end
125
+
126
+ it 'sends string message' do
127
+ websocket.send_message?('test message')
128
+ expect(driver).to have_received(:text).with('test message')
129
+ end
130
+
131
+ it 'converts hash to JSON' do
132
+ websocket.send_message?({ type: 'test', data: 'value' })
133
+ expect(driver).to have_received(:text).with('{"type":"test","data":"value"}')
134
+ end
135
+
136
+ it 'returns true' do
137
+ expect(websocket.send_message?('test')).to be(true)
138
+ end
139
+
140
+ it 'returns false when the write raises IOError' do
141
+ allow(driver).to receive(:text).and_raise(IOError)
142
+ expect(websocket.send_message?('test')).to be(false)
143
+ end
144
+ end
145
+ end
146
+
147
+ describe '#disconnect' do
148
+ let(:driver) { instance_double(WebSocket::Driver::Client, state: :open, close: nil) }
149
+ let(:socket) { instance_double(TCPSocket, close: nil) }
150
+
151
+ before do
152
+ websocket.instance_variable_set(:@driver, driver)
153
+ websocket.instance_variable_set(:@socket, socket)
154
+ websocket.instance_variable_set(:@connected, true)
155
+ end
156
+
157
+ it 'sets should_reconnect to false' do
158
+ websocket.disconnect
159
+ expect(websocket.instance_variable_get(:@should_reconnect)).to be(false)
160
+ end
161
+
162
+ it 'stops the ping timer' do
163
+ websocket.disconnect
164
+ expect(websocket.instance_variable_get(:@ping_token)).to be_nil
165
+ expect(websocket.instance_variable_get(:@ping_thread)).to be_nil
166
+ end
167
+
168
+ it 'sends a close frame' do
169
+ websocket.disconnect
170
+ expect(driver).to have_received(:close)
171
+ end
172
+
173
+ it 'closes the socket' do
174
+ websocket.disconnect
175
+ expect(socket).to have_received(:close)
176
+ end
177
+
178
+ it 'sets connected to false' do
179
+ websocket.disconnect
180
+ expect(websocket.connected).to be(false)
181
+ end
182
+
183
+ it 'logs disconnection' do
184
+ websocket.disconnect
185
+ expect(logger).to have_received(:info).with('WebSocket disconnected')
186
+ end
187
+
188
+ it 'returns self' do
189
+ expect(websocket.disconnect).to eq(websocket)
190
+ end
191
+ end
192
+
193
+ describe 'private #build_url' do
194
+ it 'builds WebSocket URL with ws scheme for http' do
195
+ url = websocket.send(:build_url)
196
+ expect(url).to eq("ws://#{api_key}:@localhost:8088/ari/events?app=#{app_name}")
197
+ end
198
+
199
+ it 'builds WebSocket URL with wss scheme for https' do
200
+ ws = described_class.new('https://localhost:8089', api_key, app_name)
201
+ url = ws.send(:build_url)
202
+ expect(url).to eq("wss://#{api_key}:@localhost:8089/ari/events?app=#{app_name}")
203
+ end
204
+
205
+ it 'includes app name in query string' do
206
+ url = websocket.send(:build_url)
207
+ expect(url).to include("app=#{app_name}")
208
+ end
209
+ end
210
+
211
+ describe 'private #dispatch_event' do
212
+ let(:event_data) { { 'type' => 'StasisStart', 'channel' => { 'id' => '123' } } }
213
+
214
+ it 'calls registered callback for event type' do
215
+ called = false
216
+ received_data = nil
217
+
218
+ websocket.on('StasisStart') do |data|
219
+ called = true
220
+ received_data = data
221
+ end
222
+
223
+ websocket.send(:dispatch_event, 'StasisStart', event_data)
224
+
225
+ expect(called).to be(true)
226
+ expect(received_data).to eq(event_data)
227
+ end
228
+
229
+ it 'calls multiple callbacks for the same event' do
230
+ call_count = 0
231
+
232
+ websocket.on('StasisStart') { call_count += 1 }
233
+ websocket.on('StasisStart') { call_count += 1 }
234
+
235
+ websocket.send(:dispatch_event, 'StasisStart', event_data)
236
+
237
+ expect(call_count).to eq(2)
238
+ end
239
+
240
+ it 'calls wildcard callbacks for any event' do
241
+ called = false
242
+ received_data = nil
243
+
244
+ websocket.on('*') do |data|
245
+ called = true
246
+ received_data = data
247
+ end
248
+
249
+ websocket.send(:dispatch_event, 'StasisStart', event_data)
250
+
251
+ expect(called).to be(true)
252
+ expect(received_data).to eq(event_data)
253
+ end
254
+
255
+ it 'logs errors in callbacks but does not raise' do
256
+ websocket.on('StasisStart') { raise 'Callback error' }
257
+
258
+ expect { websocket.send(:dispatch_event, 'StasisStart', event_data) }.not_to raise_error
259
+ expect(logger).to have_received(:error).with(/Error in event handler/)
260
+ end
261
+
262
+ it 'does not call callbacks for different event types' do
263
+ called = false
264
+ websocket.on('ChannelHangup') { called = true }
265
+
266
+ websocket.send(:dispatch_event, 'StasisStart', event_data)
267
+
268
+ expect(called).to be(false)
269
+ end
270
+ end
271
+
272
+ describe 'private #handle_message' do
273
+ let(:event_json) { '{"type":"StasisStart","channel":{"id":"123"}}' }
274
+ let(:event) { double('WebSocket::Driver::MessageEvent', data: event_json) }
275
+
276
+ it 'parses JSON and dispatches event' do
277
+ called = false
278
+ websocket.on('StasisStart') { called = true }
279
+
280
+ websocket.send(:handle_message, event)
281
+
282
+ expect(called).to be(true)
283
+ end
284
+
285
+ it 'logs received event' do
286
+ websocket.send(:handle_message, event)
287
+ expect(logger).to have_received(:debug).with('Received event: StasisStart')
288
+ end
289
+
290
+ it 'logs error for invalid JSON' do
291
+ invalid_event = double('WebSocket::Driver::MessageEvent', data: 'invalid json')
292
+
293
+ websocket.send(:handle_message, invalid_event)
294
+
295
+ expect(logger).to have_received(:error).with(/Failed to parse message/)
296
+ end
297
+ end
298
+
299
+ describe 'private #handle_open' do
300
+ let(:driver) { instance_double(WebSocket::Driver::Client) }
301
+ let(:connect_callback) { proc { |ws| } }
302
+
303
+ before do
304
+ websocket.instance_variable_set(:@driver, driver)
305
+ websocket.instance_variable_set(:@on_connect_callback, connect_callback)
306
+ allow(websocket).to receive(:start_ping_timer)
307
+ end
308
+
309
+ it 'sets connected to true' do
310
+ websocket.send(:handle_open)
311
+ expect(websocket.connected).to be(true)
312
+ end
313
+
314
+ it 'resets reconnect attempts' do
315
+ websocket.instance_variable_set(:@reconnect_attempts, 5)
316
+ websocket.send(:handle_open)
317
+ expect(websocket.instance_variable_get(:@reconnect_attempts)).to eq(0)
318
+ end
319
+
320
+ it 'logs successful connection' do
321
+ websocket.send(:handle_open)
322
+ expect(logger).to have_received(:info).with('WebSocket connected successfully')
323
+ end
324
+
325
+ it 'starts ping timer' do
326
+ websocket.send(:handle_open)
327
+ expect(websocket).to have_received(:start_ping_timer)
328
+ end
329
+
330
+ it 'calls on_connect callback' do
331
+ expect(connect_callback).to receive(:call).with(websocket)
332
+ websocket.send(:handle_open)
333
+ end
334
+ end
335
+
336
+ describe 'private #handle_close' do
337
+ let(:event) { double('WebSocket::Driver::CloseEvent', code: 1000, reason: 'Normal closure') }
338
+
339
+ before do
340
+ websocket.instance_variable_set(:@connected, true)
341
+ allow(websocket).to receive(:stop_ping_timer)
342
+ allow(websocket).to receive(:close_socket)
343
+ end
344
+
345
+ it 'sets connected to false' do
346
+ websocket.send(:handle_close, event)
347
+ expect(websocket.connected).to be(false)
348
+ end
349
+
350
+ it 'stops ping timer' do
351
+ websocket.send(:handle_close, event)
352
+ expect(websocket).to have_received(:stop_ping_timer)
353
+ end
354
+
355
+ it 'logs close event' do
356
+ websocket.send(:handle_close, event)
357
+ expect(logger).to have_received(:warn).with(/WebSocket closed/)
358
+ end
359
+
360
+ it 'closes the socket so the read loop unblocks' do
361
+ websocket.send(:handle_close, event)
362
+ expect(websocket).to have_received(:close_socket)
363
+ end
364
+ end
365
+
366
+ describe 'private #handle_error' do
367
+ let(:event) { double('WebSocket::Driver::ProtocolError', message: 'Connection failed') }
368
+
369
+ it 'logs error message' do
370
+ websocket.send(:handle_error, event)
371
+ expect(logger).to have_received(:error).with('WebSocket error: Connection failed')
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'Immutability of value objects' do
6
+ describe RubyAsterisk::Request do
7
+ subject { RubyAsterisk::Request.new('Originate', { 'Channel' => '1234', 'Context' => 'test' }) }
8
+
9
+ it 'is frozen' do
10
+ subject.should be_frozen
11
+ end
12
+
13
+ it 'has a frozen action' do
14
+ subject.action.should be_frozen
15
+ end
16
+
17
+ it 'has a frozen action_id' do
18
+ subject.action_id.should be_frozen
19
+ end
20
+
21
+ it 'has a frozen parameters hash' do
22
+ subject.parameters.should be_frozen
23
+ end
24
+
25
+ it 'has frozen parameter keys and values' do
26
+ subject.parameters.each do |key, value|
27
+ key.should be_frozen
28
+ value.should be_frozen
29
+ end
30
+ end
31
+
32
+ it 'raises FrozenError when attempting to modify instance variables' do
33
+ -> { subject.instance_variable_set(:@action, 'Login') }.should raise_error(FrozenError)
34
+ end
35
+
36
+ it 'raises FrozenError when attempting to modify parameters' do
37
+ -> { subject.parameters['NewKey'] = 'val' }.should raise_error(FrozenError)
38
+ end
39
+
40
+ it 'does not expose writer methods' do
41
+ subject.respond_to?(:action=).should be false
42
+ subject.respond_to?(:parameters=).should be false
43
+ subject.respond_to?(:action_id=).should be false
44
+ end
45
+
46
+ it 'does not expose response_data' do
47
+ subject.respond_to?(:response_data).should be false
48
+ subject.respond_to?(:response_data=).should be false
49
+ end
50
+ end
51
+
52
+ describe RubyAsterisk::Response do
53
+ let(:raw_response) do
54
+ "Response: Success\nActionID: 123\nMessage: Authentication accepted\n\n"
55
+ end
56
+ subject { RubyAsterisk::Response.new('Login', raw_response) }
57
+
58
+ it 'is frozen' do
59
+ subject.should be_frozen
60
+ end
61
+
62
+ it 'has a frozen type' do
63
+ subject.type.should be_frozen
64
+ end
65
+
66
+ it 'has a frozen action_id' do
67
+ subject.action_id.should be_frozen
68
+ end
69
+
70
+ it 'has a frozen message' do
71
+ subject.message.should be_frozen
72
+ end
73
+
74
+ it 'has a frozen raw_response' do
75
+ subject.raw_response.should be_frozen
76
+ subject.raw_response.each do |element|
77
+ element.should be_frozen
78
+ end
79
+ end
80
+
81
+ it 'has frozen data' do
82
+ subject.data.should be_frozen
83
+ end
84
+
85
+ it 'raises FrozenError when attempting to modify instance variables' do
86
+ -> { subject.instance_variable_set(:@type, 'Other') }.should raise_error(FrozenError)
87
+ end
88
+
89
+ it 'does not expose writer methods' do
90
+ subject.respond_to?(:type=).should be false
91
+ subject.respond_to?(:action_id=).should be false
92
+ subject.respond_to?(:message=).should be false
93
+ subject.respond_to?(:data=).should be false
94
+ subject.respond_to?(:raw_response=).should be false
95
+ end
96
+
97
+ context 'with parsed data containing nested structures' do
98
+ let(:raw_response) do
99
+ "Event: CoreShowChannel\nActionID: 839\nChannel: SIP/test\nCallerIDnum: 123\n\n" \
100
+ "Event: CoreShowChannelsComplete\nActionID: 839\n\n"
101
+ end
102
+ subject { RubyAsterisk::Response.new('CoreShowChannels', raw_response) }
103
+
104
+ it 'has deeply frozen data structures' do
105
+ subject.data.should be_frozen
106
+ subject.data[:channels].should be_frozen
107
+ subject.data[:channels].each do |channel|
108
+ channel.should be_frozen
109
+ channel.each do |key, value|
110
+ key.should be_frozen
111
+ value.should be_frozen
112
+ end
113
+ end
114
+ end
115
+
116
+ it 'raises FrozenError when attempting to modify nested data' do
117
+ -> { subject.data[:channels] << {} }.should raise_error(FrozenError)
118
+ end
119
+ end
120
+ end
121
+
122
+ describe RubyAsterisk::ResponseBuilder do
123
+ it 'builds a frozen Response' do
124
+ builder = RubyAsterisk::ResponseBuilder.new
125
+ builder.type = 'Login'
126
+ builder.raw_response = "Response: Success\nActionID: 123\n\n"
127
+ response = builder.build
128
+ response.should be_frozen
129
+ end
130
+
131
+ it 'raises ArgumentError when type is missing' do
132
+ builder = RubyAsterisk::ResponseBuilder.new
133
+ builder.raw_response = "Response: Success\n\n"
134
+ -> { builder.build }.should raise_error(ArgumentError, 'type is required')
135
+ end
136
+
137
+ it 'raises ArgumentError when raw_response is missing' do
138
+ builder = RubyAsterisk::ResponseBuilder.new
139
+ builder.type = 'Login'
140
+ -> { builder.build }.should raise_error(ArgumentError, 'raw_response is required')
141
+ end
142
+
143
+ it 'is not frozen itself' do
144
+ builder = RubyAsterisk::ResponseBuilder.new
145
+ builder.should_not be_frozen
146
+ end
147
+ end
148
+ end