ruby-asterisk 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +5 -5
  2. data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
  3. data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
  4. data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
  5. data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
  6. data/.github/workflows/ci.yml +79 -0
  7. data/.gitignore +48 -4
  8. data/.rubocop.yml +84 -0
  9. data/CHANGELOG.md +52 -0
  10. data/Gemfile +3 -1
  11. data/README.md +410 -108
  12. data/Rakefile +3 -5
  13. data/lib/ruby-asterisk/agi/protocol.rb +160 -0
  14. data/lib/ruby-asterisk/agi/server.rb +108 -0
  15. data/lib/ruby-asterisk/agi/session.rb +187 -0
  16. data/lib/ruby-asterisk/ami/client.rb +135 -0
  17. data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
  18. data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
  19. data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
  20. data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
  21. data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
  22. data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
  23. data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
  24. data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
  25. data/lib/ruby-asterisk/ami/event.rb +22 -0
  26. data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
  27. data/lib/ruby-asterisk/ami/parser.rb +60 -0
  28. data/lib/ruby-asterisk/ami/promise.rb +108 -0
  29. data/lib/ruby-asterisk/ami/reactor.rb +162 -0
  30. data/lib/ruby-asterisk/ari/client.rb +94 -0
  31. data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
  32. data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
  33. data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
  34. data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
  35. data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
  36. data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
  37. data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
  38. data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
  39. data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
  40. data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
  41. data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
  42. data/lib/ruby-asterisk/ari/websocket.rb +155 -0
  43. data/lib/ruby-asterisk/compat.rb +7 -0
  44. data/lib/ruby-asterisk/error.rb +10 -0
  45. data/lib/ruby-asterisk/parsing_constants.rb +71 -69
  46. data/lib/ruby-asterisk/request.rb +33 -14
  47. data/lib/ruby-asterisk/response.rb +43 -16
  48. data/lib/ruby-asterisk/response_builder.rb +18 -0
  49. data/lib/ruby-asterisk/response_parser.rb +10 -9
  50. data/lib/ruby-asterisk/version.rb +4 -2
  51. data/lib/ruby-asterisk.rb +5 -222
  52. data/ruby-asterisk.gemspec +23 -17
  53. data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
  54. data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
  55. data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
  56. data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
  57. data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
  58. data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
  59. data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
  60. data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
  61. data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
  62. data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
  63. data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
  64. data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
  65. data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
  66. data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
  67. data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
  68. data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
  69. data/spec/ruby-asterisk/immutability_spec.rb +148 -0
  70. data/spec/ruby-asterisk/request_spec.rb +29 -9
  71. data/spec/ruby-asterisk/response_spec.rb +46 -47
  72. data/spec/spec_helper.rb +12 -0
  73. data/spec/support/mock_ami_server.rb +50 -0
  74. data/spec/support/mock_ari_websocket_server.rb +114 -0
  75. metadata +138 -18
  76. data/CHANGELOG +0 -3
  77. data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -192
@@ -0,0 +1,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