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.
- checksums.yaml +5 -5
- data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
- data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
- data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
- data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
- data/.github/workflows/ci.yml +79 -0
- data/.gitignore +48 -4
- data/.rubocop.yml +84 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -1
- data/README.md +410 -108
- data/Rakefile +3 -5
- data/lib/ruby-asterisk/agi/protocol.rb +160 -0
- data/lib/ruby-asterisk/agi/server.rb +108 -0
- data/lib/ruby-asterisk/agi/session.rb +187 -0
- data/lib/ruby-asterisk/ami/client.rb +135 -0
- data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
- data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
- data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
- data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
- data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
- data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
- data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
- data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
- data/lib/ruby-asterisk/ami/event.rb +22 -0
- data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
- data/lib/ruby-asterisk/ami/parser.rb +60 -0
- data/lib/ruby-asterisk/ami/promise.rb +108 -0
- data/lib/ruby-asterisk/ami/reactor.rb +162 -0
- data/lib/ruby-asterisk/ari/client.rb +94 -0
- data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
- data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
- data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
- data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
- data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
- data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
- data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
- data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
- data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
- data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
- data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
- data/lib/ruby-asterisk/ari/websocket.rb +155 -0
- data/lib/ruby-asterisk/compat.rb +7 -0
- data/lib/ruby-asterisk/error.rb +10 -0
- data/lib/ruby-asterisk/parsing_constants.rb +71 -69
- data/lib/ruby-asterisk/request.rb +33 -14
- data/lib/ruby-asterisk/response.rb +43 -16
- data/lib/ruby-asterisk/response_builder.rb +18 -0
- data/lib/ruby-asterisk/response_parser.rb +10 -9
- data/lib/ruby-asterisk/version.rb +4 -2
- data/lib/ruby-asterisk.rb +5 -222
- data/ruby-asterisk.gemspec +23 -17
- data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
- data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
- data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
- data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
- data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
- data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
- data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
- data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
- data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
- data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
- data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
- data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
- data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
- data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
- data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
- data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
- data/spec/ruby-asterisk/immutability_spec.rb +148 -0
- data/spec/ruby-asterisk/request_spec.rb +29 -9
- data/spec/ruby-asterisk/response_spec.rb +46 -47
- data/spec/spec_helper.rb +12 -0
- data/spec/support/mock_ami_server.rb +50 -0
- data/spec/support/mock_ari_websocket_server.rb +114 -0
- metadata +138 -18
- data/CHANGELOG +0 -3
- 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
|