ruby-asterisk 0.1.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 +50 -3
- data/.rubocop.yml +84 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -1
- data/LICENCE +21 -0
- data/README.md +454 -70
- data/Rakefile +3 -1
- 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 +100 -0
- data/lib/ruby-asterisk/request.rb +38 -20
- data/lib/ruby-asterisk/response.rb +50 -137
- data/lib/ruby-asterisk/response_builder.rb +18 -0
- data/lib/ruby-asterisk/response_parser.rb +43 -0
- data/lib/ruby-asterisk/version.rb +4 -2
- data/lib/ruby-asterisk.rb +5 -147
- data/ruby-asterisk.gemspec +26 -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 +147 -148
- data/spec/spec_helper.rb +14 -0
- data/spec/support/mock_ami_server.rb +50 -0
- data/spec/support/mock_ari_websocket_server.rb +114 -0
- metadata +177 -21
- data/CHANGELOG +0 -3
- data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -150
|
@@ -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
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/ami/reactor'
|
|
5
|
+
require 'ruby-asterisk/ami/promise'
|
|
6
|
+
require 'support/mock_ami_server'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
|
|
9
|
+
RSpec.describe RubyAsterisk::AMI::Reactor do
|
|
10
|
+
# ── shared mock server setup ───────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
let(:events_received) { [] }
|
|
13
|
+
let(:on_event) { ->(evt) { events_received << evt } }
|
|
14
|
+
|
|
15
|
+
def build_reactor
|
|
16
|
+
described_class.new('localhost', @port, on_event: on_event)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Track Thread count before each example to assert no leaks after.
|
|
20
|
+
let(:thread_count_before) { Thread.list.count(&:alive?) }
|
|
21
|
+
|
|
22
|
+
# ── start / stop ─────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe 'start and stop lifecycle' do
|
|
25
|
+
before do
|
|
26
|
+
@server_thread, @port = MockAMIServer.start
|
|
27
|
+
thread_count_before # capture before reactor is started
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
after { @server_thread&.kill }
|
|
31
|
+
|
|
32
|
+
it 'starts without error and returns self' do
|
|
33
|
+
reactor = build_reactor
|
|
34
|
+
expect(reactor.start).to be(reactor)
|
|
35
|
+
reactor.stop
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'both threads are dead after stop' do
|
|
39
|
+
reactor = build_reactor
|
|
40
|
+
reactor.start
|
|
41
|
+
writer_thread = reactor.instance_variable_get(:@writer_thread)
|
|
42
|
+
reader_thread = reactor.instance_variable_get(:@reader_thread)
|
|
43
|
+
reactor.stop
|
|
44
|
+
expect(writer_thread).not_to be_alive
|
|
45
|
+
expect(reader_thread).not_to be_alive
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'can be stopped twice without error' do
|
|
49
|
+
reactor = build_reactor
|
|
50
|
+
reactor.start
|
|
51
|
+
expect do
|
|
52
|
+
reactor.stop
|
|
53
|
+
reactor.stop
|
|
54
|
+
end.not_to raise_error
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# #stop closes the socket from the writer thread while the reader thread sits
|
|
58
|
+
# in readpartial: that must surface as a clean shutdown, never as a disconnect
|
|
59
|
+
# notification or a rejected promise.
|
|
60
|
+
describe 'deliberate stop' do
|
|
61
|
+
let(:disconnects) { Thread::Queue.new }
|
|
62
|
+
let(:pending_promise) do
|
|
63
|
+
RubyAsterisk::AMI::Promise.new(action_id: 'stop-race', command_type: 'Ping', timeout: 1)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def stopped_reactor
|
|
67
|
+
reactor = described_class.new('localhost', @port, on_disconnect: -> { disconnects << :called })
|
|
68
|
+
reactor.start
|
|
69
|
+
reactor.register_promise('stop-race', pending_promise)
|
|
70
|
+
reactor.send_command("Action: Ping\r\nActionID: stop-race\r\n\r\n")
|
|
71
|
+
reactor.stop
|
|
72
|
+
reactor
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'does not notify a disconnect nor reject pending promises' do
|
|
76
|
+
stopped_reactor
|
|
77
|
+
|
|
78
|
+
expect(disconnects).to be_empty
|
|
79
|
+
expect(pending_promise).not_to be_resolved
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Replays what the reader thread does when it is scheduled only after the
|
|
83
|
+
# socket has already been closed and cleared: no notification either way.
|
|
84
|
+
it 'stays silent when the reader loop runs after the socket is gone' do
|
|
85
|
+
reactor = stopped_reactor
|
|
86
|
+
|
|
87
|
+
reactor.send(:reader_loop)
|
|
88
|
+
|
|
89
|
+
expect(disconnects).to be_empty
|
|
90
|
+
expect(pending_promise).not_to be_resolved
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ── command delivery ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe 'command delivery' do
|
|
98
|
+
let(:received_commands) { Thread::Queue.new }
|
|
99
|
+
|
|
100
|
+
before do
|
|
101
|
+
@server_thread, @port = MockAMIServer.start do |sock|
|
|
102
|
+
while (line = sock.gets)
|
|
103
|
+
received_commands << line.chomp unless line.strip.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
after do
|
|
109
|
+
@reactor&.stop
|
|
110
|
+
@server_thread&.kill
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'delivers a command string to the server socket' do
|
|
114
|
+
@reactor = build_reactor
|
|
115
|
+
@reactor.start
|
|
116
|
+
@reactor.send_command("Action: Ping\r\nActionID: test1\r\n\r\n")
|
|
117
|
+
|
|
118
|
+
line = Timeout.timeout(2) { received_commands.pop }
|
|
119
|
+
expect(line).to eq('Action: Ping')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'delivers multiple commands in order' do
|
|
123
|
+
@reactor = build_reactor
|
|
124
|
+
@reactor.start
|
|
125
|
+
|
|
126
|
+
3.times do |i|
|
|
127
|
+
@reactor.send_command("Action: Ping\r\nActionID: order#{i}\r\n\r\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
action_ids = []
|
|
131
|
+
3.times do
|
|
132
|
+
loop do
|
|
133
|
+
line = Timeout.timeout(2) { received_commands.pop }
|
|
134
|
+
if line.start_with?('ActionID:')
|
|
135
|
+
action_ids << line.split(': ', 2).last
|
|
136
|
+
break
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
expect(action_ids).to eq(%w[order0 order1 order2])
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# ── Promise resolution ────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe 'Promise resolution' do
|
|
148
|
+
before do
|
|
149
|
+
@server_thread, @port = MockAMIServer.start do |sock|
|
|
150
|
+
while (chunk = sock.gets)
|
|
151
|
+
next unless chunk.strip.empty?
|
|
152
|
+
|
|
153
|
+
# Echo a successful response for every command received
|
|
154
|
+
sock.print "Response: Success\r\nActionID: #{@last_action_id}\r\nPing: Pong\r\n\r\n"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
@action_id_queue = Thread::Queue.new
|
|
159
|
+
|
|
160
|
+
# Custom mock: capture ActionID as it arrives
|
|
161
|
+
begin
|
|
162
|
+
@server_thread.kill
|
|
163
|
+
rescue StandardError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
@server_thread, @port = MockAMIServer.start do |sock|
|
|
167
|
+
buf = +''
|
|
168
|
+
while (line = sock.gets)
|
|
169
|
+
buf << line
|
|
170
|
+
last_id = Regexp.last_match(1) if line =~ /ActionID: (\S+)/
|
|
171
|
+
next unless line.strip.empty? && last_id
|
|
172
|
+
|
|
173
|
+
sock.print "Response: Success\r\nActionID: #{last_id}\r\nPing: Pong\r\n\r\n"
|
|
174
|
+
last_id = nil
|
|
175
|
+
buf.clear
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
after do
|
|
181
|
+
@reactor&.stop
|
|
182
|
+
@server_thread&.kill
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'resolves the Promise when a matching response arrives' do
|
|
186
|
+
@reactor = build_reactor
|
|
187
|
+
@reactor.start
|
|
188
|
+
|
|
189
|
+
promise = RubyAsterisk::AMI::Promise.new(
|
|
190
|
+
action_id: 'p1', command_type: 'Ping', timeout: 3
|
|
191
|
+
)
|
|
192
|
+
@reactor.register_promise('p1', promise)
|
|
193
|
+
@reactor.send_command("Action: Ping\r\nActionID: p1\r\n\r\n")
|
|
194
|
+
|
|
195
|
+
expect(promise.resolved?).to be false
|
|
196
|
+
response = promise.value(3)
|
|
197
|
+
expect(response).not_to be_nil
|
|
198
|
+
expect(promise.resolved?).to be true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'resolves multiple in-flight Promises independently' do
|
|
202
|
+
@reactor = build_reactor
|
|
203
|
+
@reactor.start
|
|
204
|
+
|
|
205
|
+
promises = (1..5).map do |i|
|
|
206
|
+
p = RubyAsterisk::AMI::Promise.new(
|
|
207
|
+
action_id: "multi#{i}", command_type: 'Ping', timeout: 3
|
|
208
|
+
)
|
|
209
|
+
@reactor.register_promise("multi#{i}", p)
|
|
210
|
+
@reactor.send_command("Action: Ping\r\nActionID: multi#{i}\r\n\r\n")
|
|
211
|
+
p
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
promises.each do |p|
|
|
215
|
+
expect { p.value(3) }.not_to raise_error
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ── Event delivery ────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
describe 'event delivery' do
|
|
223
|
+
before do
|
|
224
|
+
@server_thread, @port = MockAMIServer.start do |sock|
|
|
225
|
+
100.times do |i|
|
|
226
|
+
sock.print "Event: TestEvent\r\nSequence: #{i}\r\n\r\n"
|
|
227
|
+
end
|
|
228
|
+
begin
|
|
229
|
+
sock.gets
|
|
230
|
+
rescue StandardError
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
after do
|
|
237
|
+
@reactor&.stop
|
|
238
|
+
@server_thread&.kill
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it 'delivers 100 events to the on_event callback' do
|
|
242
|
+
@reactor = build_reactor
|
|
243
|
+
@reactor.start
|
|
244
|
+
|
|
245
|
+
Timeout.timeout(3) do
|
|
246
|
+
sleep 0.05 until events_received.size >= 100
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
expect(events_received.size).to eq(100)
|
|
250
|
+
sequences = events_received.map { |e| e.headers['Sequence'].to_i }
|
|
251
|
+
expect(sequences).to eq((0...100).to_a)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# ── Promise rejection on disconnect ──────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe 'Promise rejection on disconnect' do
|
|
258
|
+
before do
|
|
259
|
+
@server_thread, @port = MockAMIServer.start do |sock|
|
|
260
|
+
sock.gets
|
|
261
|
+
rescue StandardError
|
|
262
|
+
nil
|
|
263
|
+
# accept but never respond
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
after { @server_thread&.kill }
|
|
268
|
+
|
|
269
|
+
it 'rejects all pending Promises when stop is called' do
|
|
270
|
+
@reactor = build_reactor
|
|
271
|
+
@reactor.start
|
|
272
|
+
|
|
273
|
+
promises = (1..5).map do |i|
|
|
274
|
+
p = RubyAsterisk::AMI::Promise.new(
|
|
275
|
+
action_id: "pend#{i}", command_type: 'Ping', timeout: 5
|
|
276
|
+
)
|
|
277
|
+
@reactor.register_promise("pend#{i}", p)
|
|
278
|
+
p
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Stop reactor while promises are still pending
|
|
282
|
+
@reactor.stop
|
|
283
|
+
@reactor.reject_all_promises(RuntimeError.new('Client disconnected'))
|
|
284
|
+
|
|
285
|
+
promises.each do |p|
|
|
286
|
+
expect { p.value(0.1) }.to raise_error(RuntimeError, /disconnected/i)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/ami/client'
|
|
5
|
+
require 'support/mock_ami_server'
|
|
6
|
+
|
|
7
|
+
# Constant to avoid CollectionLiteralInLoop
|
|
8
|
+
NEWLINES = ["\r\n", "\n"].freeze
|
|
9
|
+
|
|
10
|
+
RSpec.describe RubyAsterisk::AMI::Client do
|
|
11
|
+
before do
|
|
12
|
+
# Start server that responds to Login and Ping
|
|
13
|
+
@server_thread, @port = MockAMIServer.start do |client|
|
|
14
|
+
buffer = +''
|
|
15
|
+
while (line = client.gets)
|
|
16
|
+
buffer << line
|
|
17
|
+
next unless NEWLINES.include?(line)
|
|
18
|
+
|
|
19
|
+
# Request complete
|
|
20
|
+
if buffer =~ /ActionID: (.*?)\s/
|
|
21
|
+
action_id = Regexp.last_match(1)
|
|
22
|
+
if buffer.include?('Action: Login')
|
|
23
|
+
client.print "Response: Success\r\nActionID: #{action_id}\r\nMessage: Authentication accepted\r\n\r\n"
|
|
24
|
+
elsif buffer.include?('Action: Ping')
|
|
25
|
+
client.print "Response: Success\r\nActionID: #{action_id}\r\nPing: Pong\r\n\r\n"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
buffer.clear
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
after do
|
|
34
|
+
@server_thread&.kill
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
let(:client) { described_class.new(host: 'localhost', port: @port) }
|
|
38
|
+
|
|
39
|
+
describe '#connect' do
|
|
40
|
+
it 'connects successfully' do
|
|
41
|
+
expect(client.connect).to be true
|
|
42
|
+
expect(client.connected).to be true
|
|
43
|
+
client.disconnect
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe '#login' do
|
|
48
|
+
it 'logs in and returns a Promise that resolves to a successful Response' do
|
|
49
|
+
promise = client.login(username: 'admin', secret: 'secret')
|
|
50
|
+
expect(promise).to be_a(RubyAsterisk::AMI::Promise)
|
|
51
|
+
response = promise.value(2)
|
|
52
|
+
expect(response.success).to be true
|
|
53
|
+
client.disconnect
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#ping' do
|
|
58
|
+
it 'returns a Promise that resolves to a successful Response' do
|
|
59
|
+
client.connect
|
|
60
|
+
promise = client.ping
|
|
61
|
+
expect(promise).to be_a(RubyAsterisk::AMI::Promise)
|
|
62
|
+
response = promise.value(2)
|
|
63
|
+
expect(response).not_to be_nil
|
|
64
|
+
expect(response.success).to be true
|
|
65
|
+
client.disconnect
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|