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,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/ami/client'
|
|
5
|
+
require 'ruby-asterisk/ami/promise'
|
|
6
|
+
require 'support/mock_ami_server'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
|
|
9
|
+
# Helpers shared across examples in this file
|
|
10
|
+
ASYNC_NEWLINES = ["\r\n", "\n"].freeze
|
|
11
|
+
|
|
12
|
+
# Builds a MockAMIServer block that responds to Login and Ping commands.
|
|
13
|
+
def ami_responder
|
|
14
|
+
lambda do |sock|
|
|
15
|
+
buf = +''
|
|
16
|
+
while (line = sock.gets)
|
|
17
|
+
buf << line
|
|
18
|
+
next unless ASYNC_NEWLINES.include?(line)
|
|
19
|
+
|
|
20
|
+
if buf =~ /ActionID: (\S+)/
|
|
21
|
+
action_id = Regexp.last_match(1)
|
|
22
|
+
if buf.include?('Action: Login')
|
|
23
|
+
sock.print "Response: Success\r\nActionID: #{action_id}\r\nMessage: Authentication accepted\r\n\r\n"
|
|
24
|
+
elsif buf.include?('Action: Ping')
|
|
25
|
+
sock.print "Response: Success\r\nActionID: #{action_id}\r\nPing: Pong\r\n\r\n"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
buf.clear
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
RSpec.describe 'RubyAsterisk::AMI::Client — async behaviour' do
|
|
34
|
+
# -------------------------------------------------------------------------
|
|
35
|
+
# Shared setup: a standard mock Asterisk server
|
|
36
|
+
# -------------------------------------------------------------------------
|
|
37
|
+
before do
|
|
38
|
+
@server_thread, @port = MockAMIServer.start(&ami_responder)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
after { @server_thread&.kill }
|
|
42
|
+
|
|
43
|
+
let(:client) { RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port) }
|
|
44
|
+
|
|
45
|
+
# -------------------------------------------------------------------------
|
|
46
|
+
# execute is non-blocking
|
|
47
|
+
# -------------------------------------------------------------------------
|
|
48
|
+
describe '#execute non-blocking behaviour' do
|
|
49
|
+
it 'returns a Promise without waiting for Asterisk' do
|
|
50
|
+
client.connect
|
|
51
|
+
|
|
52
|
+
start_time = Time.now
|
|
53
|
+
promise = client.ping # internally calls execute
|
|
54
|
+
elapsed = Time.now - start_time
|
|
55
|
+
|
|
56
|
+
expect(promise).to be_a(RubyAsterisk::AMI::Promise)
|
|
57
|
+
expect(elapsed).to be < 0.5 # near-instant — no blocking wait
|
|
58
|
+
|
|
59
|
+
promise.value(2) # clean up: wait for response
|
|
60
|
+
client.disconnect
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'promise is not yet resolved right after execute' do
|
|
64
|
+
client.connect
|
|
65
|
+
promise = client.ping
|
|
66
|
+
# Promise may or may not be resolved immediately — just check it is a Promise
|
|
67
|
+
expect(promise).to be_a(RubyAsterisk::AMI::Promise)
|
|
68
|
+
promise.value(2)
|
|
69
|
+
client.disconnect
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# -------------------------------------------------------------------------
|
|
74
|
+
# Promise resolves to a proper Response
|
|
75
|
+
# -------------------------------------------------------------------------
|
|
76
|
+
describe 'Promise#value' do
|
|
77
|
+
it 'returns a RubyAsterisk::Response on success' do
|
|
78
|
+
client.connect
|
|
79
|
+
response = client.ping.value(2)
|
|
80
|
+
expect(response).to be_a(RubyAsterisk::Response)
|
|
81
|
+
expect(response.success).to be true
|
|
82
|
+
client.disconnect
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'raises Timeout::Error when no response arrives in time' do
|
|
86
|
+
# Server accepts but never responds to commands
|
|
87
|
+
silent_thread, silent_port = MockAMIServer.start { |_sock| sleep 30 }
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
slow_client = RubyAsterisk::AMI::Client.new(host: 'localhost', port: silent_port)
|
|
91
|
+
slow_client.connect
|
|
92
|
+
promise = slow_client.execute('Ping')
|
|
93
|
+
expect { promise.value(0.2) }.to raise_error(Timeout::Error)
|
|
94
|
+
slow_client.disconnect
|
|
95
|
+
ensure
|
|
96
|
+
silent_thread&.kill
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# -------------------------------------------------------------------------
|
|
102
|
+
# Multiple concurrent commands
|
|
103
|
+
# -------------------------------------------------------------------------
|
|
104
|
+
describe 'concurrent commands' do
|
|
105
|
+
it 'resolves multiple in-flight promises independently' do
|
|
106
|
+
client.connect
|
|
107
|
+
|
|
108
|
+
# Fire three pings without waiting for any of them
|
|
109
|
+
promises = Array.new(3) { client.ping }
|
|
110
|
+
|
|
111
|
+
# Now collect all responses
|
|
112
|
+
responses = promises.map { |p| p.value(2) }
|
|
113
|
+
|
|
114
|
+
expect(responses).to all(be_a(RubyAsterisk::Response))
|
|
115
|
+
expect(responses.map(&:success)).to all(be true)
|
|
116
|
+
|
|
117
|
+
client.disconnect
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# -------------------------------------------------------------------------
|
|
122
|
+
# Disconnect rejects pending promises
|
|
123
|
+
# -------------------------------------------------------------------------
|
|
124
|
+
describe '#disconnect' do
|
|
125
|
+
it 'rejects promises that are still pending' do
|
|
126
|
+
# Use a server that accepts the connection but never responds to commands
|
|
127
|
+
slow_thread, slow_port = MockAMIServer.start { |_sock| sleep 30 }
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
slow_client = RubyAsterisk::AMI::Client.new(host: 'localhost', port: slow_port)
|
|
131
|
+
slow_client.connect
|
|
132
|
+
|
|
133
|
+
promise = slow_client.execute('Ping')
|
|
134
|
+
slow_client.disconnect # tears down before response arrives
|
|
135
|
+
|
|
136
|
+
expect { promise.value(0.5) }.to raise_error(RuntimeError, /disconnected/i)
|
|
137
|
+
ensure
|
|
138
|
+
slow_thread&.kill
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'sets connected to false' do
|
|
143
|
+
client.connect
|
|
144
|
+
expect(client.connected).to be true
|
|
145
|
+
client.disconnect
|
|
146
|
+
expect(client.connected).to be false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# -------------------------------------------------------------------------
|
|
151
|
+
# A timed-out promise must not leak in the reactor's pending map
|
|
152
|
+
# -------------------------------------------------------------------------
|
|
153
|
+
describe 'pending-promise cleanup on timeout' do
|
|
154
|
+
it 'unregisters the promise after Promise#value times out' do
|
|
155
|
+
silent_thread, silent_port = MockAMIServer.start { |_sock| sleep 30 }
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
c = RubyAsterisk::AMI::Client.new(host: 'localhost', port: silent_port)
|
|
159
|
+
c.connect
|
|
160
|
+
promise = c.execute('Ping')
|
|
161
|
+
expect { promise.value(0.2) }.to raise_error(Timeout::Error)
|
|
162
|
+
|
|
163
|
+
pending = c.instance_variable_get(:@reactor).instance_variable_get(:@promises)
|
|
164
|
+
expect(pending).to be_empty
|
|
165
|
+
|
|
166
|
+
c.disconnect
|
|
167
|
+
ensure
|
|
168
|
+
silent_thread&.kill
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# -------------------------------------------------------------------------
|
|
174
|
+
# Per-command timeout must not mutate the client-wide default
|
|
175
|
+
# -------------------------------------------------------------------------
|
|
176
|
+
describe 'per-command timeout isolation' do
|
|
177
|
+
it 'does not raise the shared default timeout' do
|
|
178
|
+
client.connect
|
|
179
|
+
expect(client.instance_variable_get(:@timeout)).to eq(5)
|
|
180
|
+
|
|
181
|
+
client.originate('SIP/100', 'default', '200', '1', timeout: 300_000)
|
|
182
|
+
|
|
183
|
+
expect(client.instance_variable_get(:@timeout)).to eq(5)
|
|
184
|
+
client.disconnect
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# -------------------------------------------------------------------------
|
|
189
|
+
# A caller-supplied ActionID replaces the generated one instead of being
|
|
190
|
+
# appended as a second ActionID header (which would break correlation)
|
|
191
|
+
# -------------------------------------------------------------------------
|
|
192
|
+
describe 'caller-supplied ActionID' do
|
|
193
|
+
it 'sends exactly one ActionID header and still resolves the promise' do
|
|
194
|
+
frames = Thread::Queue.new
|
|
195
|
+
echo_thread, echo_port = MockAMIServer.start do |sock|
|
|
196
|
+
buf = +''
|
|
197
|
+
while (line = sock.gets)
|
|
198
|
+
buf << line
|
|
199
|
+
next unless ASYNC_NEWLINES.include?(line)
|
|
200
|
+
|
|
201
|
+
frames << buf.dup
|
|
202
|
+
sock.print "Response: Success\r\nActionID: #{buf[/ActionID: (\S+)/, 1]}\r\n\r\n"
|
|
203
|
+
buf.clear
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
begin
|
|
208
|
+
c = RubyAsterisk::AMI::Client.new(host: 'localhost', port: echo_port)
|
|
209
|
+
c.connect
|
|
210
|
+
response = c.status(channel: 'SIP/alice-001', action_id: 'custom-id').value(2)
|
|
211
|
+
|
|
212
|
+
frame = frames.pop
|
|
213
|
+
expect(frame.scan(/^ActionID:/).size).to eq(1)
|
|
214
|
+
expect(frame).to include("ActionID: custom-id\r\n")
|
|
215
|
+
expect(response.success).to be true
|
|
216
|
+
|
|
217
|
+
c.disconnect
|
|
218
|
+
ensure
|
|
219
|
+
echo_thread&.kill
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# -------------------------------------------------------------------------
|
|
225
|
+
# WaitEvent with a negative Timeout must not impose a Promise deadline
|
|
226
|
+
# -------------------------------------------------------------------------
|
|
227
|
+
describe '#wait_event' do
|
|
228
|
+
it 'leaves the promise without a deadline when Timeout is negative' do
|
|
229
|
+
client.connect
|
|
230
|
+
|
|
231
|
+
promise = client.wait_event(timeout: -1)
|
|
232
|
+
|
|
233
|
+
expect(promise.instance_variable_get(:@timeout)).to be_nil
|
|
234
|
+
client.disconnect
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'keeps the client default as the floor for a positive Timeout' do
|
|
238
|
+
client.connect
|
|
239
|
+
|
|
240
|
+
promise = client.wait_event(timeout: 30)
|
|
241
|
+
|
|
242
|
+
expect(promise.instance_variable_get(:@timeout)).to eq(30)
|
|
243
|
+
client.disconnect
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# -------------------------------------------------------------------------
|
|
248
|
+
# Commands issued before connect fail loudly rather than hanging
|
|
249
|
+
# -------------------------------------------------------------------------
|
|
250
|
+
describe 'guard when not connected' do
|
|
251
|
+
it 'raises instead of NoMethodError' do
|
|
252
|
+
fresh = RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port)
|
|
253
|
+
expect { fresh.execute('Ping') }.to raise_error(RubyAsterisk::Error, /not connected/i)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/ami/client'
|
|
5
|
+
require 'support/mock_ami_server'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
# Regression coverage for AMI EventList (multi-frame) responses. A list action
|
|
9
|
+
# replies with an ack frame (EventList: start), then one Event frame per item,
|
|
10
|
+
# then a terminating *Complete event — each a separate \r\n\r\n frame carrying
|
|
11
|
+
# the same ActionID. The Reactor must aggregate them before resolving so that
|
|
12
|
+
# ResponseParser can extract the structured data.
|
|
13
|
+
FRAME_NEWLINES = ["\r\n", "\n"].freeze
|
|
14
|
+
|
|
15
|
+
def list_responder
|
|
16
|
+
lambda do |sock|
|
|
17
|
+
buf = +''
|
|
18
|
+
while (line = sock.gets)
|
|
19
|
+
buf << line
|
|
20
|
+
next unless FRAME_NEWLINES.include?(line)
|
|
21
|
+
|
|
22
|
+
if buf.include?('Action: CoreShowChannels') && buf =~ /ActionID: (\S+)/
|
|
23
|
+
id = Regexp.last_match(1)
|
|
24
|
+
sock.print "Response: Success\r\nActionID: #{id}\r\nEventList: start\r\n" \
|
|
25
|
+
"Message: Channels will follow\r\n\r\n"
|
|
26
|
+
sock.print "Event: CoreShowChannel\r\nActionID: #{id}\r\n" \
|
|
27
|
+
"Channel: SIP/100-00000001\r\nChannelState: 6\r\n\r\n"
|
|
28
|
+
sock.print "Event: CoreShowChannel\r\nActionID: #{id}\r\n" \
|
|
29
|
+
"Channel: SIP/200-00000002\r\nChannelState: 6\r\n\r\n"
|
|
30
|
+
sock.print "Event: CoreShowChannelsComplete\r\nActionID: #{id}\r\n" \
|
|
31
|
+
"EventList: Complete\r\nListItems: 2\r\n\r\n"
|
|
32
|
+
end
|
|
33
|
+
buf.clear
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
RSpec.describe 'RubyAsterisk::AMI::Client — EventList aggregation' do
|
|
39
|
+
before { @server_thread, @port = MockAMIServer.start(&list_responder) }
|
|
40
|
+
after { @server_thread&.kill }
|
|
41
|
+
|
|
42
|
+
let(:client) { RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port) }
|
|
43
|
+
|
|
44
|
+
it 'aggregates the ack, event, and Complete frames into one Response' do
|
|
45
|
+
client.connect
|
|
46
|
+
response = client.core_show_channels.value(2)
|
|
47
|
+
|
|
48
|
+
expect(response).to be_a(RubyAsterisk::Response)
|
|
49
|
+
expect(response.data).to be_a(Hash)
|
|
50
|
+
channels = response.data[:channels]
|
|
51
|
+
expect(channels.size).to eq(2)
|
|
52
|
+
expect(channels.map { |c| c['Channel'] })
|
|
53
|
+
.to contain_exactly('SIP/100-00000001', 'SIP/200-00000002')
|
|
54
|
+
|
|
55
|
+
client.disconnect
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/ami/parser'
|
|
5
|
+
|
|
6
|
+
RSpec.describe RubyAsterisk::AMI::Parser do
|
|
7
|
+
# Helper: push chunks into a buffer, drain, collect yielded messages.
|
|
8
|
+
def drain_chunks(*chunks)
|
|
9
|
+
buffer = +''
|
|
10
|
+
msgs = []
|
|
11
|
+
chunks.each do |chunk|
|
|
12
|
+
buffer << chunk
|
|
13
|
+
described_class.drain(buffer) { |m| msgs << m }
|
|
14
|
+
end
|
|
15
|
+
msgs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# ── parse_headers ───────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe '.parse_headers' do
|
|
21
|
+
it 'parses key-value lines' do
|
|
22
|
+
raw = "Response: Success\r\nActionID: abc\r\nMessage: OK\r\n"
|
|
23
|
+
h = described_class.parse_headers(raw)
|
|
24
|
+
expect(h['Response']).to eq('Success')
|
|
25
|
+
expect(h['ActionID']).to eq('abc')
|
|
26
|
+
expect(h['Message']).to eq('OK')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'trims leading/trailing whitespace from values' do
|
|
30
|
+
h = described_class.parse_headers("Response: Success\r\nMessage: hello world \r\n")
|
|
31
|
+
expect(h['Message']).to eq('hello world')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'preserves colons inside values' do
|
|
35
|
+
h = described_class.parse_headers("Event: NewExten\r\nAppData: SIP/101:60\r\n")
|
|
36
|
+
expect(h['AppData']).to eq('SIP/101:60')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'skips lines without a colon separator (e.g. AMI welcome banner)' do
|
|
40
|
+
h = described_class.parse_headers("Asterisk Call Manager/1.1\nResponse: Success\r\n")
|
|
41
|
+
expect(h.keys).to eq(['Response'])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'returns a frozen hash with frozen keys and values' do
|
|
45
|
+
h = described_class.parse_headers("Response: OK\r\nActionID: x\r\n")
|
|
46
|
+
expect(h).to be_frozen
|
|
47
|
+
h.each_key { |k| expect(k).to be_frozen }
|
|
48
|
+
h.each_value { |v| expect(v).to be_frozen }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ── drain / split-packet reassembly ─────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe '.drain — split-packet reassembly' do
|
|
55
|
+
it 'assembles a Response split across two chunks' do
|
|
56
|
+
msgs = drain_chunks(
|
|
57
|
+
"Response: Success\r\nActionID: abc123\r\n",
|
|
58
|
+
"Message: OK\r\n\r\n"
|
|
59
|
+
)
|
|
60
|
+
expect(msgs.size).to eq(1)
|
|
61
|
+
expect(msgs.first[:type]).to eq(:response)
|
|
62
|
+
expect(msgs.first[:action_id]).to eq('abc123')
|
|
63
|
+
expect(msgs.first[:headers]['Message']).to eq('OK')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'assembles an Event split across three chunks' do
|
|
67
|
+
msgs = drain_chunks(
|
|
68
|
+
'Event: Han',
|
|
69
|
+
"gup\r\nChannel: SIP/",
|
|
70
|
+
"101\r\n\r\n"
|
|
71
|
+
)
|
|
72
|
+
expect(msgs.size).to eq(1)
|
|
73
|
+
expect(msgs.first[:type]).to eq(:event)
|
|
74
|
+
expect(msgs.first[:event].name).to eq('Hangup')
|
|
75
|
+
expect(msgs.first[:event].headers['Channel']).to eq('SIP/101')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'emits nothing until the delimiter arrives' do
|
|
79
|
+
msgs = drain_chunks("Response: Success\r\nActionID: xyz\r\n")
|
|
80
|
+
expect(msgs).to be_empty
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'emits the message once the delimiter arrives' do
|
|
84
|
+
msgs = drain_chunks(
|
|
85
|
+
"Response: Success\r\nActionID: xyz\r\n",
|
|
86
|
+
"\r\n"
|
|
87
|
+
)
|
|
88
|
+
expect(msgs.size).to eq(1)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ── multiple messages in one chunk ─────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe '.drain — multiple messages in one chunk' do
|
|
95
|
+
it 'emits all messages when two complete messages arrive together' do
|
|
96
|
+
two_msgs =
|
|
97
|
+
"Response: Success\r\nActionID: r1\r\n\r\n" \
|
|
98
|
+
"Event: FullyBooted\r\nPrivilege: system,all\r\nStatus: Fully Booted\r\n\r\n"
|
|
99
|
+
|
|
100
|
+
msgs = drain_chunks(two_msgs)
|
|
101
|
+
expect(msgs.size).to eq(2)
|
|
102
|
+
expect(msgs[0][:type]).to eq(:response)
|
|
103
|
+
expect(msgs[0][:action_id]).to eq('r1')
|
|
104
|
+
expect(msgs[1][:type]).to eq(:event)
|
|
105
|
+
expect(msgs[1][:event].name).to eq('FullyBooted')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'handles ten back-to-back events in a single chunk' do
|
|
109
|
+
chunk = (1..10).map { |i| "Event: TestEvent\r\nSequence: #{i}\r\n\r\n" }.join
|
|
110
|
+
msgs = drain_chunks(chunk)
|
|
111
|
+
expect(msgs.size).to eq(10)
|
|
112
|
+
sequences = msgs.map { |m| m[:event].headers['Sequence'].to_i }
|
|
113
|
+
expect(sequences).to eq((1..10).to_a)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ── Response routing ────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe 'response routing' do
|
|
120
|
+
it 'sets action_id from the ActionID header' do
|
|
121
|
+
msgs = drain_chunks("Response: Success\r\nActionID: myid\r\n\r\n")
|
|
122
|
+
expect(msgs.first[:action_id]).to eq('myid')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'sets action_id to nil when no ActionID header is present' do
|
|
126
|
+
msgs = drain_chunks("Response: Follows\r\nPrivilege: Command\r\n\r\n")
|
|
127
|
+
expect(msgs.first[:type]).to eq(:response)
|
|
128
|
+
expect(msgs.first[:action_id]).to be_nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'exposes a frozen raw string' do
|
|
132
|
+
msgs = drain_chunks("Response: Error\r\nActionID: f2\r\n\r\n")
|
|
133
|
+
expect(msgs.first[:raw]).to be_frozen
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# ── Event routing ───────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe 'event routing' do
|
|
140
|
+
it 'creates a frozen Event object with the correct name and headers' do
|
|
141
|
+
msgs = drain_chunks("Event: Hangup\r\nChannel: SIP/200\r\nCause: 16\r\n\r\n")
|
|
142
|
+
event = msgs.first[:event]
|
|
143
|
+
expect(event).to be_a(RubyAsterisk::AMI::Event)
|
|
144
|
+
expect(event).to be_frozen
|
|
145
|
+
expect(event.name).to eq('Hangup')
|
|
146
|
+
expect(event.headers['Cause']).to eq('16')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'Event object exposes the raw AMI message string' do
|
|
150
|
+
raw_input = "Event: Hangup\r\nChannel: SIP/200\r\n\r\n"
|
|
151
|
+
msgs = drain_chunks(raw_input)
|
|
152
|
+
expect(msgs.first[:event].raw).to eq(raw_input)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ── AMI welcome banner tolerance ────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe 'AMI welcome banner tolerance' do
|
|
159
|
+
it 'ignores the banner and still parses the subsequent response' do
|
|
160
|
+
combined =
|
|
161
|
+
"Asterisk Call Manager/1.1\n" \
|
|
162
|
+
"Response: Success\r\nActionID: banner_test\r\n\r\n"
|
|
163
|
+
|
|
164
|
+
msgs = drain_chunks(combined)
|
|
165
|
+
expect(msgs.size).to eq(1)
|
|
166
|
+
expect(msgs.first[:type]).to eq(:response)
|
|
167
|
+
expect(msgs.first[:action_id]).to eq('banner_test')
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ── build_message ────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe '.build_message' do
|
|
174
|
+
it 'returns nil for frames with no recognised Response/Event header' do
|
|
175
|
+
expect(described_class.build_message("Asterisk Call Manager/1.1\r\n\r\n")).to be_nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'returns a frozen response hash' do
|
|
179
|
+
msg = described_class.build_message("Response: OK\r\nActionID: x\r\n\r\n")
|
|
180
|
+
expect(msg).to be_frozen
|
|
181
|
+
expect(msg[:type]).to eq(:response)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'returns a frozen event hash' do
|
|
185
|
+
msg = described_class.build_message("Event: Hangup\r\n\r\n")
|
|
186
|
+
expect(msg).to be_frozen
|
|
187
|
+
expect(msg[:type]).to eq(:event)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|