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,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ruby-asterisk/agi/session'
5
+ require 'stringio'
6
+
7
+ RSpec.describe RubyAsterisk::AGI::Session do
8
+ # A fake socket backed by StringIO for reads and a StringIO capture for writes.
9
+ def fake_socket(input_string, responses: [])
10
+ reader = StringIO.new(input_string)
11
+ writer = StringIO.new
12
+ socket = double('socket')
13
+
14
+ allow(socket).to receive(:gets) do
15
+ line = reader.gets
16
+ line || responses.shift
17
+ end
18
+ allow(socket).to receive(:write) { |data| writer.write(data) }
19
+
20
+ [socket, writer]
21
+ end
22
+
23
+ # Builds a socket whose reads cycle through env_block then response_lines
24
+ def session_socket(env_block, response_lines)
25
+ all_input = env_block + response_lines.map { |l| "#{l}\n" }.join
26
+ socket, writer = fake_socket(all_input)
27
+ [socket, writer]
28
+ end
29
+
30
+ let(:logger) { instance_double(Logger, debug: nil, info: nil, warn: nil, error: nil) }
31
+
32
+ describe '#read_env' do
33
+ it 'parses agi_* key/value pairs from the env block' do
34
+ env_block = "agi_channel: SIP/test-1\nagi_callerid: 1000\n\n"
35
+ socket, = fake_socket(env_block)
36
+ session = described_class.new(socket, logger: logger)
37
+ session.read_env
38
+
39
+ expect(session.env['agi_channel']).to eq('SIP/test-1')
40
+ expect(session.env['agi_callerid']).to eq('1000')
41
+ end
42
+
43
+ it 'stops at the blank line' do
44
+ env_block = "agi_channel: SIP/test-1\n\nagi_after_blank: ignored\n"
45
+ socket, = fake_socket(env_block)
46
+ session = described_class.new(socket, logger: logger)
47
+ session.read_env
48
+
49
+ expect(session.env).not_to have_key('agi_after_blank')
50
+ end
51
+
52
+ it 'ignores non-agi lines with a debug log' do
53
+ env_block = "agi_channel: SIP/x\nunknown line\n\n"
54
+ socket, = fake_socket(env_block)
55
+ session = described_class.new(socket, logger: logger)
56
+ session.read_env
57
+
58
+ expect(logger).to have_received(:debug).with(/unexpected line/)
59
+ expect(session.env.keys).to eq(['agi_channel'])
60
+ end
61
+ end
62
+
63
+ describe '#execute' do
64
+ subject(:session) { described_class.new(socket, logger: logger) }
65
+
66
+ let(:socket) do
67
+ s, = session_socket('', ['200 result=1'])
68
+ s
69
+ end
70
+
71
+ it 'returns a parsed response hash' do
72
+ result = session.execute('ANSWER')
73
+ expect(result).to eq(code: 200, result: '1', data: nil)
74
+ end
75
+
76
+ it 'raises RubyAsterisk::Error on 5xx response' do
77
+ s = double('socket')
78
+ allow(s).to receive(:write)
79
+ allow(s).to receive(:gets).and_return("510 Invalid or unknown command\n")
80
+ session = described_class.new(s, logger: logger)
81
+
82
+ expect { session.execute('BADCMD') }.to raise_error(RubyAsterisk::Error, /510/)
83
+ end
84
+
85
+ it 'raises RubyAsterisk::Error on EOF (nil)' do
86
+ s = double('socket')
87
+ allow(s).to receive(:write)
88
+ allow(s).to receive(:gets).and_return(nil)
89
+ session = described_class.new(s, logger: logger)
90
+
91
+ expect { session.execute('ANSWER') }.to raise_error(RubyAsterisk::Error)
92
+ end
93
+ end
94
+
95
+ describe 'command wrappers' do
96
+ let(:written) { StringIO.new }
97
+ let(:socket) do
98
+ s = double('socket')
99
+ allow(s).to receive(:write) { |data| written.write(data) }
100
+ allow(s).to receive(:gets).and_return("200 result=1\n")
101
+ s
102
+ end
103
+ subject(:session) { described_class.new(socket, logger: logger) }
104
+
105
+ it '#answer writes ANSWER command' do
106
+ session.answer
107
+ expect(written.string).to eq("ANSWER\n")
108
+ end
109
+
110
+ it '#hangup writes HANGUP without channel' do
111
+ session.hangup
112
+ expect(written.string).to eq("HANGUP\n")
113
+ end
114
+
115
+ it '#hangup writes HANGUP with channel' do
116
+ session.hangup('SIP/1001')
117
+ expect(written.string).to eq("HANGUP SIP/1001\n")
118
+ end
119
+
120
+ it '#stream_file quotes filename and escape_digits' do
121
+ session.stream_file('hello-world', '#*')
122
+ expect(written.string).to eq(%(STREAM FILE "hello-world" "#*"\n))
123
+ end
124
+
125
+ it '#stream_file uses empty escape_digits by default' do
126
+ session.stream_file('hello-world')
127
+ expect(written.string).to eq(%(STREAM FILE "hello-world" ""\n))
128
+ end
129
+
130
+ it '#say_digits writes SAY DIGITS command' do
131
+ session.say_digits('1234')
132
+ expect(written.string).to eq(%(SAY DIGITS 1234 ""\n))
133
+ end
134
+
135
+ it '#say_number writes SAY NUMBER command' do
136
+ session.say_number(42)
137
+ expect(written.string).to eq(%(SAY NUMBER 42 ""\n))
138
+ end
139
+
140
+ it '#exec writes EXEC command with joined args' do
141
+ session.exec('AGICommand', 'arg1', 'arg2')
142
+ expect(written.string).to eq(%(EXEC AGICommand "arg1,arg2"\n))
143
+ end
144
+
145
+ it '#set_variable writes SET VARIABLE command' do
146
+ session.set_variable('MYVAR', 'hello')
147
+ expect(written.string).to eq(%(SET VARIABLE MYVAR "hello"\n))
148
+ end
149
+
150
+ it '#get_variable writes GET VARIABLE command' do
151
+ session.get_variable('MYVAR')
152
+ expect(written.string).to eq("GET VARIABLE MYVAR\n")
153
+ end
154
+
155
+ it '#get_data writes GET DATA command with defaults' do
156
+ session.get_data('silence')
157
+ expect(written.string).to eq(%(GET DATA "silence" 5000 1024\n))
158
+ end
159
+
160
+ it '#get_data accepts timeout and max_digits overrides' do
161
+ session.get_data('beep', timeout: 3000, max_digits: 4)
162
+ expect(written.string).to eq(%(GET DATA "beep" 3000 4\n))
163
+ end
164
+
165
+ it '#verbose writes VERBOSE command' do
166
+ session.verbose('test message', level: 2)
167
+ expect(written.string).to eq(%(VERBOSE "test message" 2\n))
168
+ end
169
+
170
+ it '#exec escapes args containing commas correctly' do
171
+ session.exec('AGI', 'arg,with,commas', 'say "hi"')
172
+ expect(written.string).to eq(%(EXEC AGI "arg,with,commas,say \\"hi\\""\n))
173
+ end
174
+
175
+ # -- Telephony commands ---------------------------------------------------
176
+
177
+ it '#dial writes EXEC Dial with target and timeout' do
178
+ session.dial('SIP/1001', timeout: 30)
179
+ expect(written.string).to eq(%(EXEC Dial "SIP/1001,30"\n))
180
+ end
181
+
182
+ it '#dial includes options when provided' do
183
+ session.dial('SIP/1001', timeout: 15, options: 'r')
184
+ expect(written.string).to eq(%(EXEC Dial "SIP/1001,15,r"\n))
185
+ end
186
+
187
+ it '#wait_for_digit writes WAIT FOR DIGIT with timeout' do
188
+ session.wait_for_digit(3000)
189
+ expect(written.string).to eq("WAIT FOR DIGIT 3000\n")
190
+ end
191
+
192
+ it '#wait_for_digit uses 5000ms default timeout' do
193
+ session.wait_for_digit
194
+ expect(written.string).to eq("WAIT FOR DIGIT 5000\n")
195
+ end
196
+
197
+ it '#record_file writes RECORD FILE with beep by default' do
198
+ session.record_file('recording', format: 'wav', escape_digits: '#', timeout_ms: 10_000, offset: 0)
199
+ expect(written.string).to eq(%(RECORD FILE "recording" wav "#" 10000 0 BEEP\n))
200
+ end
201
+
202
+ it '#record_file includes silence param when provided' do
203
+ session.record_file('msg', silence: 5)
204
+ expect(written.string).to eq(%(RECORD FILE "msg" wav "#" -1 0 BEEP s=5\n))
205
+ end
206
+
207
+ it '#record_file omits BEEP when beep: false' do
208
+ session.record_file('msg', beep: false)
209
+ expect(written.string).to eq(%(RECORD FILE "msg" wav "#" -1 0\n))
210
+ end
211
+
212
+ it '#send_text writes SEND TEXT with quoted message' do
213
+ session.send_text('hello world')
214
+ expect(written.string).to eq(%(SEND TEXT "hello world"\n))
215
+ end
216
+
217
+ it '#send_image writes SEND IMAGE with filename' do
218
+ session.send_image('logo.png')
219
+ expect(written.string).to eq("SEND IMAGE logo.png\n")
220
+ end
221
+
222
+ it '#channel_status writes CHANNEL STATUS without arg by default' do
223
+ session.channel_status
224
+ expect(written.string).to eq("CHANNEL STATUS\n")
225
+ end
226
+
227
+ it '#channel_status writes CHANNEL STATUS with a channel name' do
228
+ session.channel_status('SIP/1001')
229
+ expect(written.string).to eq("CHANNEL STATUS SIP/1001\n")
230
+ end
231
+
232
+ # -- AstDB commands -------------------------------------------------------
233
+
234
+ it '#database_get writes DATABASE GET command' do
235
+ session.database_get('family', 'key')
236
+ expect(written.string).to eq("DATABASE GET family key\n")
237
+ end
238
+
239
+ it '#database_put writes DATABASE PUT command with quoted value' do
240
+ session.database_put('family', 'key', 'hello world')
241
+ expect(written.string).to eq(%(DATABASE PUT family key "hello world"\n))
242
+ end
243
+
244
+ it '#database_del writes DATABASE DEL command' do
245
+ session.database_del('family', 'key')
246
+ expect(written.string).to eq("DATABASE DEL family key\n")
247
+ end
248
+
249
+ it '#database_deltree writes DATABASE DELTREE without subtree' do
250
+ session.database_deltree('family')
251
+ expect(written.string).to eq("DATABASE DELTREE family\n")
252
+ end
253
+
254
+ it '#database_deltree writes DATABASE DELTREE with subtree' do
255
+ session.database_deltree('family', 'sub/tree')
256
+ expect(written.string).to eq("DATABASE DELTREE family sub/tree\n")
257
+ end
258
+ end
259
+
260
+ describe 'multi-line 520 error handling' do
261
+ let(:logger) { instance_double(Logger, debug: nil, info: nil, warn: nil, error: nil) }
262
+
263
+ it 'raises with the accumulated body for a 520- intro followed by 520 closer' do
264
+ lines = [
265
+ "520-Invalid command syntax. Proper usage follows:\n",
266
+ "520-Usage: WAIT FOR DIGIT <timeout>\n",
267
+ "520 End of proper usage.\n"
268
+ ]
269
+ socket = double('socket')
270
+ allow(socket).to receive(:write)
271
+ allow(socket).to receive(:gets).and_return(*lines)
272
+ session = described_class.new(socket, logger: logger)
273
+
274
+ expect { session.execute('BADCMD') }
275
+ .to raise_error(RubyAsterisk::Error, /Invalid command syntax/)
276
+ end
277
+
278
+ it 'accumulates the usage lines into the error message' do
279
+ lines = [
280
+ "520-Invalid command syntax. Proper usage follows:\n",
281
+ "520-Usage: WAIT FOR DIGIT <timeout>\n",
282
+ "520 End of proper usage.\n"
283
+ ]
284
+ socket = double('socket')
285
+ allow(socket).to receive(:write)
286
+ allow(socket).to receive(:gets).and_return(*lines)
287
+ session = described_class.new(socket, logger: logger)
288
+
289
+ expect { session.execute('BADCMD') }
290
+ .to raise_error(RubyAsterisk::Error, /WAIT FOR DIGIT/)
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ruby-asterisk/ami/client'
5
+ require 'ruby-asterisk/ami/promise'
6
+ require 'support/mock_ami_server'
7
+ require 'timeout'
8
+
9
+ # Helpers shared across examples in this file
10
+ ASYNC_NEWLINES = ["\r\n", "\n"].freeze
11
+
12
+ # Builds a MockAMIServer block that responds to Login and Ping commands.
13
+ def ami_responder
14
+ lambda do |sock|
15
+ buf = +''
16
+ while (line = sock.gets)
17
+ buf << line
18
+ next unless ASYNC_NEWLINES.include?(line)
19
+
20
+ if buf =~ /ActionID: (\S+)/
21
+ action_id = Regexp.last_match(1)
22
+ if buf.include?('Action: Login')
23
+ sock.print "Response: Success\r\nActionID: #{action_id}\r\nMessage: Authentication accepted\r\n\r\n"
24
+ elsif buf.include?('Action: Ping')
25
+ sock.print "Response: Success\r\nActionID: #{action_id}\r\nPing: Pong\r\n\r\n"
26
+ end
27
+ end
28
+ buf.clear
29
+ end
30
+ end
31
+ end
32
+
33
+ RSpec.describe 'RubyAsterisk::AMI::Client — async behaviour' do
34
+ # -------------------------------------------------------------------------
35
+ # Shared setup: a standard mock Asterisk server
36
+ # -------------------------------------------------------------------------
37
+ before do
38
+ @server_thread, @port = MockAMIServer.start(&ami_responder)
39
+ end
40
+
41
+ after { @server_thread&.kill }
42
+
43
+ let(:client) { RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port) }
44
+
45
+ # -------------------------------------------------------------------------
46
+ # execute is non-blocking
47
+ # -------------------------------------------------------------------------
48
+ describe '#execute non-blocking behaviour' do
49
+ it 'returns a Promise without waiting for Asterisk' do
50
+ client.connect
51
+
52
+ start_time = Time.now
53
+ promise = client.ping # internally calls execute
54
+ elapsed = Time.now - start_time
55
+
56
+ expect(promise).to be_a(RubyAsterisk::AMI::Promise)
57
+ expect(elapsed).to be < 0.5 # near-instant — no blocking wait
58
+
59
+ promise.value(2) # clean up: wait for response
60
+ client.disconnect
61
+ end
62
+
63
+ it 'promise is not yet resolved right after execute' do
64
+ client.connect
65
+ promise = client.ping
66
+ # Promise may or may not be resolved immediately — just check it is a Promise
67
+ expect(promise).to be_a(RubyAsterisk::AMI::Promise)
68
+ promise.value(2)
69
+ client.disconnect
70
+ end
71
+ end
72
+
73
+ # -------------------------------------------------------------------------
74
+ # Promise resolves to a proper Response
75
+ # -------------------------------------------------------------------------
76
+ describe 'Promise#value' do
77
+ it 'returns a RubyAsterisk::Response on success' do
78
+ client.connect
79
+ response = client.ping.value(2)
80
+ expect(response).to be_a(RubyAsterisk::Response)
81
+ expect(response.success).to be true
82
+ client.disconnect
83
+ end
84
+
85
+ it 'raises Timeout::Error when no response arrives in time' do
86
+ # Server accepts but never responds to commands
87
+ silent_thread, silent_port = MockAMIServer.start { |_sock| sleep 30 }
88
+
89
+ begin
90
+ slow_client = RubyAsterisk::AMI::Client.new(host: 'localhost', port: silent_port)
91
+ slow_client.connect
92
+ promise = slow_client.execute('Ping')
93
+ expect { promise.value(0.2) }.to raise_error(Timeout::Error)
94
+ slow_client.disconnect
95
+ ensure
96
+ silent_thread&.kill
97
+ end
98
+ end
99
+ end
100
+
101
+ # -------------------------------------------------------------------------
102
+ # Multiple concurrent commands
103
+ # -------------------------------------------------------------------------
104
+ describe 'concurrent commands' do
105
+ it 'resolves multiple in-flight promises independently' do
106
+ client.connect
107
+
108
+ # Fire three pings without waiting for any of them
109
+ promises = Array.new(3) { client.ping }
110
+
111
+ # Now collect all responses
112
+ responses = promises.map { |p| p.value(2) }
113
+
114
+ expect(responses).to all(be_a(RubyAsterisk::Response))
115
+ expect(responses.map(&:success)).to all(be true)
116
+
117
+ client.disconnect
118
+ end
119
+ end
120
+
121
+ # -------------------------------------------------------------------------
122
+ # Disconnect rejects pending promises
123
+ # -------------------------------------------------------------------------
124
+ describe '#disconnect' do
125
+ it 'rejects promises that are still pending' do
126
+ # Use a server that accepts the connection but never responds to commands
127
+ slow_thread, slow_port = MockAMIServer.start { |_sock| sleep 30 }
128
+
129
+ begin
130
+ slow_client = RubyAsterisk::AMI::Client.new(host: 'localhost', port: slow_port)
131
+ slow_client.connect
132
+
133
+ promise = slow_client.execute('Ping')
134
+ slow_client.disconnect # tears down before response arrives
135
+
136
+ expect { promise.value(0.5) }.to raise_error(RuntimeError, /disconnected/i)
137
+ ensure
138
+ slow_thread&.kill
139
+ end
140
+ end
141
+
142
+ it 'sets connected to false' do
143
+ client.connect
144
+ expect(client.connected).to be true
145
+ client.disconnect
146
+ expect(client.connected).to be false
147
+ end
148
+ end
149
+
150
+ # -------------------------------------------------------------------------
151
+ # A timed-out promise must not leak in the reactor's pending map
152
+ # -------------------------------------------------------------------------
153
+ describe 'pending-promise cleanup on timeout' do
154
+ it 'unregisters the promise after Promise#value times out' do
155
+ silent_thread, silent_port = MockAMIServer.start { |_sock| sleep 30 }
156
+
157
+ begin
158
+ c = RubyAsterisk::AMI::Client.new(host: 'localhost', port: silent_port)
159
+ c.connect
160
+ promise = c.execute('Ping')
161
+ expect { promise.value(0.2) }.to raise_error(Timeout::Error)
162
+
163
+ pending = c.instance_variable_get(:@reactor).instance_variable_get(:@promises)
164
+ expect(pending).to be_empty
165
+
166
+ c.disconnect
167
+ ensure
168
+ silent_thread&.kill
169
+ end
170
+ end
171
+ end
172
+
173
+ # -------------------------------------------------------------------------
174
+ # Per-command timeout must not mutate the client-wide default
175
+ # -------------------------------------------------------------------------
176
+ describe 'per-command timeout isolation' do
177
+ it 'does not raise the shared default timeout' do
178
+ client.connect
179
+ expect(client.instance_variable_get(:@timeout)).to eq(5)
180
+
181
+ client.originate('SIP/100', 'default', '200', '1', timeout: 300_000)
182
+
183
+ expect(client.instance_variable_get(:@timeout)).to eq(5)
184
+ client.disconnect
185
+ end
186
+ end
187
+
188
+ # -------------------------------------------------------------------------
189
+ # A caller-supplied ActionID replaces the generated one instead of being
190
+ # appended as a second ActionID header (which would break correlation)
191
+ # -------------------------------------------------------------------------
192
+ describe 'caller-supplied ActionID' do
193
+ it 'sends exactly one ActionID header and still resolves the promise' do
194
+ frames = Thread::Queue.new
195
+ echo_thread, echo_port = MockAMIServer.start do |sock|
196
+ buf = +''
197
+ while (line = sock.gets)
198
+ buf << line
199
+ next unless ASYNC_NEWLINES.include?(line)
200
+
201
+ frames << buf.dup
202
+ sock.print "Response: Success\r\nActionID: #{buf[/ActionID: (\S+)/, 1]}\r\n\r\n"
203
+ buf.clear
204
+ end
205
+ end
206
+
207
+ begin
208
+ c = RubyAsterisk::AMI::Client.new(host: 'localhost', port: echo_port)
209
+ c.connect
210
+ response = c.status(channel: 'SIP/alice-001', action_id: 'custom-id').value(2)
211
+
212
+ frame = frames.pop
213
+ expect(frame.scan(/^ActionID:/).size).to eq(1)
214
+ expect(frame).to include("ActionID: custom-id\r\n")
215
+ expect(response.success).to be true
216
+
217
+ c.disconnect
218
+ ensure
219
+ echo_thread&.kill
220
+ end
221
+ end
222
+ end
223
+
224
+ # -------------------------------------------------------------------------
225
+ # WaitEvent with a negative Timeout must not impose a Promise deadline
226
+ # -------------------------------------------------------------------------
227
+ describe '#wait_event' do
228
+ it 'leaves the promise without a deadline when Timeout is negative' do
229
+ client.connect
230
+
231
+ promise = client.wait_event(timeout: -1)
232
+
233
+ expect(promise.instance_variable_get(:@timeout)).to be_nil
234
+ client.disconnect
235
+ end
236
+
237
+ it 'keeps the client default as the floor for a positive Timeout' do
238
+ client.connect
239
+
240
+ promise = client.wait_event(timeout: 30)
241
+
242
+ expect(promise.instance_variable_get(:@timeout)).to eq(30)
243
+ client.disconnect
244
+ end
245
+ end
246
+
247
+ # -------------------------------------------------------------------------
248
+ # Commands issued before connect fail loudly rather than hanging
249
+ # -------------------------------------------------------------------------
250
+ describe 'guard when not connected' do
251
+ it 'raises instead of NoMethodError' do
252
+ fresh = RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port)
253
+ expect { fresh.execute('Ping') }.to raise_error(RubyAsterisk::Error, /not connected/i)
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ruby-asterisk/ami/client'
5
+ require 'support/mock_ami_server'
6
+ require 'timeout'
7
+
8
+ # Regression coverage for AMI EventList (multi-frame) responses. A list action
9
+ # replies with an ack frame (EventList: start), then one Event frame per item,
10
+ # then a terminating *Complete event — each a separate \r\n\r\n frame carrying
11
+ # the same ActionID. The Reactor must aggregate them before resolving so that
12
+ # ResponseParser can extract the structured data.
13
+ FRAME_NEWLINES = ["\r\n", "\n"].freeze
14
+
15
+ def list_responder
16
+ lambda do |sock|
17
+ buf = +''
18
+ while (line = sock.gets)
19
+ buf << line
20
+ next unless FRAME_NEWLINES.include?(line)
21
+
22
+ if buf.include?('Action: CoreShowChannels') && buf =~ /ActionID: (\S+)/
23
+ id = Regexp.last_match(1)
24
+ sock.print "Response: Success\r\nActionID: #{id}\r\nEventList: start\r\n" \
25
+ "Message: Channels will follow\r\n\r\n"
26
+ sock.print "Event: CoreShowChannel\r\nActionID: #{id}\r\n" \
27
+ "Channel: SIP/100-00000001\r\nChannelState: 6\r\n\r\n"
28
+ sock.print "Event: CoreShowChannel\r\nActionID: #{id}\r\n" \
29
+ "Channel: SIP/200-00000002\r\nChannelState: 6\r\n\r\n"
30
+ sock.print "Event: CoreShowChannelsComplete\r\nActionID: #{id}\r\n" \
31
+ "EventList: Complete\r\nListItems: 2\r\n\r\n"
32
+ end
33
+ buf.clear
34
+ end
35
+ end
36
+ end
37
+
38
+ RSpec.describe 'RubyAsterisk::AMI::Client — EventList aggregation' do
39
+ before { @server_thread, @port = MockAMIServer.start(&list_responder) }
40
+ after { @server_thread&.kill }
41
+
42
+ let(:client) { RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port) }
43
+
44
+ it 'aggregates the ack, event, and Complete frames into one Response' do
45
+ client.connect
46
+ response = client.core_show_channels.value(2)
47
+
48
+ expect(response).to be_a(RubyAsterisk::Response)
49
+ expect(response.data).to be_a(Hash)
50
+ channels = response.data[:channels]
51
+ expect(channels.size).to eq(2)
52
+ expect(channels.map { |c| c['Channel'] })
53
+ .to contain_exactly('SIP/100-00000001', 'SIP/200-00000002')
54
+
55
+ client.disconnect
56
+ end
57
+ end