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,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ruby-asterisk/agi/server'
5
+ require 'socket'
6
+ require 'timeout'
7
+
8
+ # Starts an AGI server on an ephemeral port and returns [server, server_thread, port].
9
+ # Automatically registers an after hook that calls stop and joins the thread.
10
+ def start_test_server(server, &)
11
+ server.handle(&)
12
+ thread = Thread.new { server.run }
13
+ Timeout.timeout(2) { sleep 0.01 until server.running? || !thread.alive? }
14
+ raise 'Server did not start' unless server.running?
15
+
16
+ [thread, server.port]
17
+ end
18
+
19
+ # Connect as a fake Asterisk client, send env + responses, collect written commands.
20
+ def fake_asterisk_session(port, env_lines: [], responses: [])
21
+ socket = TCPSocket.new('127.0.0.1', port)
22
+ # Send env block
23
+ env_lines.each { |l| socket.puts(l) }
24
+ socket.puts('') # blank line terminates env
25
+
26
+ received_commands = []
27
+ responses.each do |resp|
28
+ cmd = socket.gets
29
+ received_commands << cmd.chomp if cmd
30
+ socket.puts(resp)
31
+ end
32
+
33
+ socket.close
34
+ received_commands
35
+ rescue StandardError => e
36
+ socket&.close
37
+ raise e
38
+ end
39
+
40
+ RSpec.describe RubyAsterisk::AGI::Server do
41
+ let(:logger) { instance_double(Logger, info: nil, warn: nil, error: nil, debug: nil) }
42
+ subject(:server) { described_class.new('127.0.0.1', 0, logger: logger) }
43
+
44
+ after do
45
+ server.stop if server.running?
46
+ end
47
+
48
+ describe '#initialize' do
49
+ it 'stores the host' do
50
+ expect(server.host).to eq('127.0.0.1')
51
+ end
52
+
53
+ it 'starts with running? false' do
54
+ expect(server.running?).to be(false)
55
+ end
56
+ end
57
+
58
+ describe '#handle' do
59
+ it 'stores the handler and returns self' do
60
+ result = server.handle { |s| s }
61
+ expect(result).to be(server)
62
+ end
63
+ end
64
+
65
+ describe '#run' do
66
+ it 'raises RubyAsterisk::Error when no handler is registered' do
67
+ expect { server.run }.to raise_error(RubyAsterisk::Error, /No handler registered/)
68
+ end
69
+
70
+ it 'blocks and sets running? to true while accepting' do
71
+ _thread, _port = start_test_server(server) { |_s| nil }
72
+ expect(server.running?).to be(true)
73
+ end
74
+
75
+ it 'sets running? back to false after stop' do
76
+ thread, = start_test_server(server) { |_s| nil }
77
+ server.stop
78
+ thread.join(2)
79
+ expect(server.running?).to be(false)
80
+ end
81
+
82
+ it 'updates port to the actual bound port when given 0' do
83
+ start_test_server(server) { |_s| nil }
84
+ expect(server.port).to be > 0
85
+ end
86
+ end
87
+
88
+ describe 'connection handling' do
89
+ it 'invokes the handler with a Session for each connection' do
90
+ sessions_seen = []
91
+ _thread, port = start_test_server(server) do |session|
92
+ sessions_seen << session
93
+ end
94
+
95
+ fake_asterisk_session(port, env_lines: ['agi_channel: SIP/test'])
96
+ sleep 0.1
97
+ server.stop
98
+
99
+ expect(sessions_seen.length).to eq(1)
100
+ expect(sessions_seen.first).to be_a(RubyAsterisk::AGI::Session)
101
+ end
102
+
103
+ it 'populates session.env from the AGI environment block' do
104
+ captured_env = nil
105
+ _thread, port = start_test_server(server) do |session|
106
+ captured_env = session.env.dup
107
+ end
108
+
109
+ fake_asterisk_session(port, env_lines: ['agi_channel: SIP/alice', 'agi_callerid: 5551234'])
110
+ sleep 0.1
111
+ server.stop
112
+
113
+ expect(captured_env['agi_channel']).to eq('SIP/alice')
114
+ expect(captured_env['agi_callerid']).to eq('5551234')
115
+ end
116
+
117
+ it 'exchanges AGI commands and responses correctly' do
118
+ _thread, port = start_test_server(server) do |session|
119
+ session.answer
120
+ session.hangup
121
+ end
122
+
123
+ cmds = fake_asterisk_session(port,
124
+ env_lines: ['agi_channel: SIP/test'],
125
+ responses: ['200 result=1', '200 result=1'])
126
+ server.stop
127
+
128
+ expect(cmds).to eq(%w[ANSWER HANGUP])
129
+ end
130
+
131
+ it 'does not crash the accept loop when a handler raises' do
132
+ call_count = 0
133
+ _thread, port = start_test_server(server) do |_session|
134
+ call_count += 1
135
+ raise 'deliberate error' if call_count == 1
136
+ end
137
+
138
+ fake_asterisk_session(port, env_lines: [])
139
+ sleep 0.05
140
+ fake_asterisk_session(port, env_lines: [])
141
+ sleep 0.05
142
+ server.stop
143
+
144
+ expect(call_count).to eq(2)
145
+ expect(logger).to have_received(:error).at_least(:once)
146
+ end
147
+ end
148
+
149
+ describe 'concurrency' do
150
+ it 'handles multiple simultaneous connections using Fiber scheduling' do
151
+ guard = Mutex.new
152
+ started = 0
153
+ finished = 0
154
+ captured_scheduler = nil
155
+
156
+ _thread, port = start_test_server(server) do |_session|
157
+ guard.synchronize do
158
+ started += 1
159
+ captured_scheduler ||= Fiber.scheduler
160
+ end
161
+ sleep 0.2
162
+ guard.synchronize { finished += 1 }
163
+ end
164
+
165
+ start = Time.now
166
+
167
+ Array.new(5) do
168
+ Thread.new do
169
+ fake_asterisk_session(port, env_lines: [])
170
+ rescue StandardError
171
+ nil
172
+ end
173
+ end.each(&:join)
174
+
175
+ # Wait until all 5 handlers have started.
176
+ Timeout.timeout(2) { sleep 0.01 until started == 5 }
177
+
178
+ server.stop
179
+
180
+ # With Fiber scheduling all 5 handlers run concurrently, so finishing
181
+ # takes ~0.2s regardless of connection count.
182
+ Timeout.timeout(2) { sleep 0.01 until finished == 5 }
183
+ elapsed = Time.now - start
184
+
185
+ expect(finished).to eq(5)
186
+ expect(elapsed).to be < 0.8
187
+ # Proves handlers ran inside an Async Fiber scheduler, not threads.
188
+ expect(captured_scheduler).not_to be_nil
189
+ end
190
+ end
191
+
192
+ describe '#stop' do
193
+ it 'is idempotent — calling twice does not raise' do
194
+ start_test_server(server) { |_s| nil }
195
+ server.stop
196
+ expect { server.stop }.not_to raise_error
197
+ end
198
+ end
199
+ end
@@ -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