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,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
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby-asterisk/error'
|
|
4
|
+
require 'ruby-asterisk/ari/client'
|
|
5
|
+
|
|
6
|
+
RSpec.describe RubyAsterisk::ARI::Client do
|
|
7
|
+
let(:base_url) { 'http://localhost:8088' }
|
|
8
|
+
let(:api_key) { 'asterisk_api_key' }
|
|
9
|
+
let(:app_name) { 'stasis_app' }
|
|
10
|
+
|
|
11
|
+
let(:stubs) { Faraday::Adapter::Test::Stubs.new }
|
|
12
|
+
let(:test_conn) do
|
|
13
|
+
Faraday.new do |conn|
|
|
14
|
+
conn.adapter :test, stubs
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
subject(:client) { described_class.new(base_url, api_key, app_name) }
|
|
19
|
+
|
|
20
|
+
before do
|
|
21
|
+
client.instance_variable_set(:@connection, test_conn)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe '#initialize' do
|
|
25
|
+
it 'stores base_url' do
|
|
26
|
+
expect(client.base_url).to eq(base_url)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'stores app_name' do
|
|
30
|
+
expect(client.app_name).to eq(app_name)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe '#get' do
|
|
35
|
+
context 'when the request succeeds' do
|
|
36
|
+
before do
|
|
37
|
+
stubs.get('/ari/channels') do
|
|
38
|
+
[200, { 'Content-Type' => 'application/json' }, '[{"id":"12345"}]']
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'returns parsed JSON' do
|
|
43
|
+
result = client.get('/ari/channels')
|
|
44
|
+
expect(result).to eq([{ 'id' => '12345' }])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context 'when the server returns 404' do
|
|
49
|
+
before do
|
|
50
|
+
stubs.get('/ari/channels/missing') do
|
|
51
|
+
[404, { 'Content-Type' => 'application/json' }, '{"message":"Channel not found"}']
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'raises RubyAsterisk::Error' do
|
|
56
|
+
expect { client.get('/ari/channels/missing') }.to raise_error(RubyAsterisk::Error)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
context 'when the server returns 500' do
|
|
61
|
+
before do
|
|
62
|
+
stubs.get('/ari/channels') do
|
|
63
|
+
[500, { 'Content-Type' => 'application/json' }, '{"message":"Internal Server Error"}']
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'raises RubyAsterisk::Error' do
|
|
68
|
+
expect { client.get('/ari/channels') }.to raise_error(RubyAsterisk::Error)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe '#post' do
|
|
74
|
+
context 'when the request succeeds' do
|
|
75
|
+
before do
|
|
76
|
+
stubs.post('/ari/channels') do
|
|
77
|
+
[200, { 'Content-Type' => 'application/json' }, '{"id":"new_channel"}']
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'returns parsed JSON' do
|
|
82
|
+
result = client.post('/ari/channels', { endpoint: 'SIP/alice' })
|
|
83
|
+
expect(result).to eq({ 'id' => 'new_channel' })
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
context 'when the server returns 400' do
|
|
88
|
+
before do
|
|
89
|
+
stubs.post('/ari/channels') do
|
|
90
|
+
[400, { 'Content-Type' => 'application/json' }, '{"message":"Missing parameter"}']
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'raises RubyAsterisk::Error with the server message' do
|
|
95
|
+
expect { client.post('/ari/channels', {}) }.to raise_error(RubyAsterisk::Error, 'Missing parameter')
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe '#delete' do
|
|
101
|
+
context 'when the request succeeds' do
|
|
102
|
+
before do
|
|
103
|
+
stubs.delete('/ari/channels/12345') do
|
|
104
|
+
[204, {}, '']
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'returns nil for an empty body' do
|
|
109
|
+
result = client.delete('/ari/channels/12345')
|
|
110
|
+
expect(result).to be_nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
context 'when the server returns 404' do
|
|
115
|
+
before do
|
|
116
|
+
stubs.delete('/ari/channels/missing') do
|
|
117
|
+
[404, { 'Content-Type' => 'application/json' }, '{"message":"Channel not found"}']
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'raises RubyAsterisk::Error' do
|
|
122
|
+
expect { client.delete('/ari/channels/missing') }.to raise_error(RubyAsterisk::Error)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe '#channels' do
|
|
128
|
+
it 'returns a Collection for Channel resources' do
|
|
129
|
+
expect(client.channels).to be_a(RubyAsterisk::ARI::Resources::Collection)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe '#bridges' do
|
|
134
|
+
it 'returns a Collection for Bridge resources' do
|
|
135
|
+
expect(client.bridges).to be_a(RubyAsterisk::ARI::Resources::Collection)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
describe '#playbacks' do
|
|
140
|
+
it 'returns a Collection for Playback resources' do
|
|
141
|
+
expect(client.playbacks).to be_a(RubyAsterisk::ARI::Resources::Collection)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
describe '#endpoints' do
|
|
146
|
+
it 'returns a Collection for Endpoint resources' do
|
|
147
|
+
expect(client.endpoints).to be_a(RubyAsterisk::ARI::Resources::Collection)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe '#asterisk_info' do
|
|
152
|
+
let(:info_payload) do
|
|
153
|
+
{ 'build' => { 'os' => 'Linux' }, 'system' => { 'entity_id' => 'asterisk' } }.to_json
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
before do
|
|
157
|
+
stubs.get('/ari/asterisk/info') do
|
|
158
|
+
[200, { 'Content-Type' => 'application/json' }, info_payload]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'performs GET /ari/asterisk/info and returns parsed data' do
|
|
163
|
+
result = client.asterisk_info
|
|
164
|
+
expect(result).to be_a(Hash)
|
|
165
|
+
expect(result).to have_key('build')
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby-asterisk/ari/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyAsterisk::ARI::Resources::Bridge do
|
|
6
|
+
let(:client) { instance_double(RubyAsterisk::ARI::Client) }
|
|
7
|
+
let(:data) { { 'id' => 'bridge-456' } }
|
|
8
|
+
|
|
9
|
+
subject(:bridge) { described_class.new(data, client) }
|
|
10
|
+
|
|
11
|
+
describe '#id' do
|
|
12
|
+
it 'returns the bridge id from data' do
|
|
13
|
+
expect(bridge.id).to eq('bridge-456')
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#add_channel' do
|
|
18
|
+
it 'posts to the addChannel endpoint with the channel id' do
|
|
19
|
+
body = { channel: 'channel-123' }
|
|
20
|
+
allow(client).to receive(:post).with('/ari/bridges/bridge-456/addChannel', body).and_return(nil)
|
|
21
|
+
bridge.add_channel('channel-123')
|
|
22
|
+
expect(client).to have_received(:post).with('/ari/bridges/bridge-456/addChannel', body)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '#remove_channel' do
|
|
27
|
+
it 'posts to the removeChannel endpoint with the channel id' do
|
|
28
|
+
body = { channel: 'channel-123' }
|
|
29
|
+
allow(client).to receive(:post).with('/ari/bridges/bridge-456/removeChannel', body).and_return(nil)
|
|
30
|
+
bridge.remove_channel('channel-123')
|
|
31
|
+
expect(client).to have_received(:post).with('/ari/bridges/bridge-456/removeChannel', body)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe '#destroy' do
|
|
36
|
+
it 'deletes the bridge' do
|
|
37
|
+
allow(client).to receive(:delete).with('/ari/bridges/bridge-456').and_return(nil)
|
|
38
|
+
bridge.destroy
|
|
39
|
+
expect(client).to have_received(:delete).with('/ari/bridges/bridge-456')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby-asterisk/ari/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyAsterisk::ARI::Resources::Channel do
|
|
6
|
+
let(:client) { instance_double(RubyAsterisk::ARI::Client) }
|
|
7
|
+
let(:data) { { 'id' => 'channel-123' } }
|
|
8
|
+
|
|
9
|
+
subject(:channel) { described_class.new(data, client) }
|
|
10
|
+
|
|
11
|
+
describe '#id' do
|
|
12
|
+
it 'returns the channel id from data' do
|
|
13
|
+
expect(channel.id).to eq('channel-123')
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#ring' do
|
|
18
|
+
it 'posts to the ring endpoint' do
|
|
19
|
+
allow(client).to receive(:post).with('/ari/channels/channel-123/ring').and_return(nil)
|
|
20
|
+
channel.ring
|
|
21
|
+
expect(client).to have_received(:post).with('/ari/channels/channel-123/ring')
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe '#answer' do
|
|
26
|
+
it 'posts to the answer endpoint' do
|
|
27
|
+
allow(client).to receive(:post).with('/ari/channels/channel-123/answer').and_return(nil)
|
|
28
|
+
channel.answer
|
|
29
|
+
expect(client).to have_received(:post).with('/ari/channels/channel-123/answer')
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#hangup' do
|
|
34
|
+
it 'deletes the channel' do
|
|
35
|
+
allow(client).to receive(:delete).with('/ari/channels/channel-123').and_return(nil)
|
|
36
|
+
channel.hangup
|
|
37
|
+
expect(client).to have_received(:delete).with('/ari/channels/channel-123')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '#play' do
|
|
42
|
+
let(:expected_body) { { media: 'sound:hello-world' } }
|
|
43
|
+
|
|
44
|
+
it 'posts to the play endpoint with the media parameter' do
|
|
45
|
+
allow(client).to receive(:post).with('/ari/channels/channel-123/play', expected_body).and_return({})
|
|
46
|
+
channel.play('sound:hello-world')
|
|
47
|
+
expect(client).to have_received(:post).with('/ari/channels/channel-123/play', expected_body)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'merges additional params into the request body' do
|
|
51
|
+
body = { media: 'sound:hello-world', lang: 'en' }
|
|
52
|
+
allow(client).to receive(:post).with('/ari/channels/channel-123/play', body).and_return({})
|
|
53
|
+
channel.play('sound:hello-world', { lang: 'en' })
|
|
54
|
+
expect(client).to have_received(:post).with('/ari/channels/channel-123/play', body)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby-asterisk/ari/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyAsterisk::ARI::Resources::Collection do
|
|
6
|
+
let(:client) { instance_double(RubyAsterisk::ARI::Client) }
|
|
7
|
+
let(:resource_class) { RubyAsterisk::ARI::Resources::Channel }
|
|
8
|
+
|
|
9
|
+
subject(:collection) { described_class.new(resource_class, '/ari/channels', client) }
|
|
10
|
+
|
|
11
|
+
describe '#get' do
|
|
12
|
+
let(:channel_data) { { 'id' => 'channel-123' } }
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
allow(client).to receive(:get).with('/ari/channels/channel-123').and_return(channel_data)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'fetches the resource by id' do
|
|
19
|
+
collection.get('channel-123')
|
|
20
|
+
expect(client).to have_received(:get).with('/ari/channels/channel-123')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'returns the resource wrapped in the correct class' do
|
|
24
|
+
result = collection.get('channel-123')
|
|
25
|
+
expect(result).to be_a(RubyAsterisk::ARI::Resources::Channel)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'passes the client to the resource' do
|
|
29
|
+
result = collection.get('channel-123')
|
|
30
|
+
expect(result.client).to eq(client)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'passes the data to the resource' do
|
|
34
|
+
result = collection.get('channel-123')
|
|
35
|
+
expect(result.data).to eq(channel_data)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '#list' do
|
|
40
|
+
let(:channels_data) { [{ 'id' => 'ch-1' }, { 'id' => 'ch-2' }] }
|
|
41
|
+
|
|
42
|
+
before do
|
|
43
|
+
allow(client).to receive(:get).with('/ari/channels', {}).and_return(channels_data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'fetches the full list of resources' do
|
|
47
|
+
collection.list
|
|
48
|
+
expect(client).to have_received(:get).with('/ari/channels', {})
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'returns an array of wrapped resources' do
|
|
52
|
+
result = collection.list
|
|
53
|
+
expect(result).to all(be_a(RubyAsterisk::ARI::Resources::Channel))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'returns the correct number of resources' do
|
|
57
|
+
expect(collection.list.size).to eq(2)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'accepts query params' do
|
|
61
|
+
allow(client).to receive(:get).with('/ari/channels', { state: 'Up' }).and_return(channels_data)
|
|
62
|
+
collection.list({ state: 'Up' })
|
|
63
|
+
expect(client).to have_received(:get).with('/ari/channels', { state: 'Up' })
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|