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.
Files changed (78) 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 +50 -3
  8. data/.rubocop.yml +84 -0
  9. data/CHANGELOG.md +52 -0
  10. data/Gemfile +3 -1
  11. data/LICENCE +21 -0
  12. data/README.md +454 -70
  13. data/Rakefile +3 -1
  14. data/lib/ruby-asterisk/agi/protocol.rb +160 -0
  15. data/lib/ruby-asterisk/agi/server.rb +108 -0
  16. data/lib/ruby-asterisk/agi/session.rb +187 -0
  17. data/lib/ruby-asterisk/ami/client.rb +135 -0
  18. data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
  19. data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
  20. data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
  21. data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
  22. data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
  23. data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
  24. data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
  25. data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
  26. data/lib/ruby-asterisk/ami/event.rb +22 -0
  27. data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
  28. data/lib/ruby-asterisk/ami/parser.rb +60 -0
  29. data/lib/ruby-asterisk/ami/promise.rb +108 -0
  30. data/lib/ruby-asterisk/ami/reactor.rb +162 -0
  31. data/lib/ruby-asterisk/ari/client.rb +94 -0
  32. data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
  33. data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
  34. data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
  35. data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
  36. data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
  37. data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
  38. data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
  39. data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
  40. data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
  41. data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
  42. data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
  43. data/lib/ruby-asterisk/ari/websocket.rb +155 -0
  44. data/lib/ruby-asterisk/compat.rb +7 -0
  45. data/lib/ruby-asterisk/error.rb +10 -0
  46. data/lib/ruby-asterisk/parsing_constants.rb +100 -0
  47. data/lib/ruby-asterisk/request.rb +38 -20
  48. data/lib/ruby-asterisk/response.rb +50 -137
  49. data/lib/ruby-asterisk/response_builder.rb +18 -0
  50. data/lib/ruby-asterisk/response_parser.rb +43 -0
  51. data/lib/ruby-asterisk/version.rb +4 -2
  52. data/lib/ruby-asterisk.rb +5 -147
  53. data/ruby-asterisk.gemspec +26 -17
  54. data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
  55. data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
  56. data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
  57. data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
  58. data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
  59. data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
  60. data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
  61. data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
  62. data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
  63. data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
  64. data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
  65. data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
  66. data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
  67. data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
  68. data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
  69. data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
  70. data/spec/ruby-asterisk/immutability_spec.rb +148 -0
  71. data/spec/ruby-asterisk/request_spec.rb +29 -9
  72. data/spec/ruby-asterisk/response_spec.rb +147 -148
  73. data/spec/spec_helper.rb +14 -0
  74. data/spec/support/mock_ami_server.rb +50 -0
  75. data/spec/support/mock_ari_websocket_server.rb +114 -0
  76. metadata +177 -21
  77. data/CHANGELOG +0 -3
  78. 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