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
data/lib/ruby-asterisk.rb CHANGED
@@ -1,223 +1,6 @@
1
- require 'ruby-asterisk/version'
2
- require 'ruby-asterisk/request'
3
- require 'ruby-asterisk/response'
4
- require 'net/telnet'
1
+ # frozen_string_literal: true
5
2
 
6
- module RubyAsterisk
7
- ##
8
- #
9
- # Ruby-asterisk main classes
10
- #
11
- class AMI
12
- attr_accessor :host, :port, :connected, :timeout, :wait_time
13
-
14
- def initialize(host, port)
15
- self.host = host.to_s
16
- self.port = port.to_i
17
- self.connected = false
18
- @timeout = 5
19
- @wait_time = 0.1
20
- @session = nil
21
- end
22
-
23
- def connect
24
- begin
25
- @session = Net::Telnet::new('Host' => self.host, 'Port' => self.port, 'Timeout' => 10)
26
- self.connected = true
27
- rescue Exception => ex
28
- false
29
- end
30
- end
31
-
32
- def disconnect
33
- begin
34
- @session.close if self.connected
35
- self.connected = false
36
- true
37
- rescue Exception => ex
38
- puts ex
39
- false
40
- end
41
- end
42
-
43
- def login(username, password)
44
- self.connect unless self.connected
45
- execute 'Login', {'Username' => username, 'Secret' => password, 'Event' => 'On'}
46
- end
47
-
48
- def logoff
49
- execute 'Logoff'
50
- end
51
-
52
- def command(command)
53
- execute 'Command', {'Command' => command}
54
- end
55
-
56
- def core_show_channels
57
- execute 'CoreShowChannels'
58
- end
59
-
60
- def meet_me_list
61
- execute 'MeetMeList'
62
- end
63
-
64
- def confbridges
65
- execute 'ConfbridgeListRooms'
66
- end
67
-
68
- def confbridge(conference)
69
- execute 'ConfbridgeList', {'Conference' => conference}
70
- end
71
-
72
- def confbridge_mute(conference, channel)
73
- execute 'ConfbridgeMute', {'Conference' => conference, 'Channel' => channel}
74
- end
75
-
76
- def confbridge_unmute(conference, channel)
77
- execute 'ConfbridgeUnmute', {'Conference' => conference, 'Channel' => channel}
78
- end
79
-
80
- def confbridge_kick(conference, channel)
81
- execute 'ConfbridgeKick', {'Conference' => conference, 'Channel' => channel}
82
- end
83
-
84
- def parked_calls
85
- execute 'ParkedCalls'
86
- end
87
-
88
- def extension_state(exten, context, action_id = nil)
89
- execute 'ExtensionState', {'Exten' => exten, 'Context' => context, 'ActionID' => action_id}
90
- end
91
-
92
- def device_state_list
93
- execute 'DeviceStateList'
94
- end
95
-
96
- def skinny_devices
97
- execute 'SKINNYdevices'
98
- end
99
-
100
- def skinny_lines
101
- execute 'SKINNYlines'
102
- end
103
-
104
- def status(channel = nil, action_id = nil)
105
- execute 'Status', {'Channel' => channel, 'ActionID' => action_id}
106
- end
107
-
108
- def originate(channel, context, callee, priority, variable = nil, caller_id = nil, timeout = 30000)
109
- @timeout = [@timeout, timeout/1000].max
110
- execute 'Originate', {'Channel' => channel, 'Context' => context, 'Exten' => callee, 'Priority' => priority, 'CallerID' => caller_id || channel, 'Timeout' => timeout.to_s, 'Variable' => variable }
111
- end
112
-
113
- def originate_app(caller, app, data, async)
114
- execute 'Originate', {'Channel' => caller, 'Application' => app, 'Data' => data, 'Timeout' => '30000', 'Async' => async }
115
- end
116
-
117
- def channels
118
- execute 'Command', { 'Command' => 'show channels' }
119
- end
120
-
121
- def redirect(channel, context, callee, priority, variable=nil, caller_id = nil, timeout = 30000)
122
- @timeout = [@timeout, timeout/1000].max
123
- execute 'Redirect', {'Channel' => channel, 'Context' => context, 'Exten' => callee, 'Priority' => priority, 'CallerID' => caller_id || channel, 'Timeout' => timeout.to_s, 'Variable' => variable}
124
- end
125
-
126
- def queues
127
- execute 'Queues', {}
128
- end
129
-
130
- def queue_add(queue, exten, penalty = 2, paused = false, member_name = '')
131
- execute 'QueueAdd', {'Queue' => queue, 'Interface' => exten, 'Penalty' => penalty, 'Paused' => paused, 'MemberName' => member_name}
132
- end
133
-
134
- def queue_pause(exten, paused)
135
- execute 'QueuePause', {'Interface' => exten, 'Paused' => paused}
136
- end
137
-
138
- def queue_remove(queue, exten)
139
- execute 'QueueRemove', {'Queue' => queue, 'Interface' => exten}
140
- end
141
-
142
- def queue_status
143
- execute 'QueueStatus'
144
- end
145
-
146
- def queue_summary(queue)
147
- execute 'QueueSummary', {'Queue' => queue}
148
- end
149
-
150
- def mailbox_status(exten, context='default')
151
- execute 'MailboxStatus', {'Mailbox' => "#{exten}@#{context}"}
152
- end
153
-
154
- def mailbox_count(exten, context='default')
155
- execute 'MailboxCount', {'Mailbox' => "#{exten}@#{context}"}
156
- end
157
-
158
- def queue_pause(interface,paused,queue,reason='none')
159
- execute 'QueuePause', {'Interface' => interface, 'Paused' => paused, 'Queue' => queue, 'Reason' => reason}
160
- end
161
-
162
- def ping
163
- execute 'Ping'
164
- end
165
-
166
- def event_mask(event_mask='off')
167
- execute 'Events', {'EventMask' => event_mask}
168
- end
169
-
170
- def sip_peers
171
- execute 'SIPpeers'
172
- end
173
-
174
- def sip_show_peer(peer)
175
- execute 'SIPshowpeer', {'Peer' => peer}
176
- end
177
-
178
- def hangup(channel)
179
- execute 'Hangup', {'Channel' => channel}
180
- end
181
-
182
- def atxfer(channel, exten, context, priority = '1')
183
- execute 'Atxfer', {'Channel' => channel, 'Exten' => exten.to_s, 'Context' => context, 'Priority' => priority}
184
- end
185
-
186
- def wait_event(timeout=-1)
187
- @timeout = [@timeout, timeout].max
188
- execute 'WaitEvent', {'Timeout' => timeout}
189
- end
190
-
191
- def monitor(channel,mix=false,file=nil,format='wav')
192
- execute 'Monitor', {'Channel' => channel, 'File' => file, 'Mix' => mix}
193
- end
194
-
195
- def stop_monitor(channel)
196
- execute 'StopMonitor', {'Channel' => channel}
197
- end
198
-
199
- def pause_monitor(channel)
200
- execute 'PauseMonitor', {'Channel' => channel}
201
- end
202
-
203
- def unpause_monitor(channel)
204
- execute 'UnpauseMonitor', {'Channel' => channel}
205
- end
206
-
207
- def change_monitor(channel,file)
208
- execute 'ChangeMonitor', {'Channel' => channel, 'File' => file}
209
- end
210
-
211
- private
212
- def execute(command, options = {})
213
- request = Request.new(command, options)
214
- request.commands.each do |command|
215
- @session.write(command)
216
- end
217
- @session.waitfor('Match' => /ActionID: #{request.action_id}.*?\n\n/m, "Timeout" => @timeout, "Waittime" => @wait_time) do |data|
218
- request.response_data << data.to_s
219
- end
220
- Response.new(command, request.response_data)
221
- end
222
- end
223
- end
3
+ require 'ruby-asterisk/ami/client'
4
+ require 'ruby-asterisk/ari/client'
5
+ require 'ruby-asterisk/ari/websocket'
6
+ require 'ruby-asterisk/agi/server'
@@ -1,27 +1,33 @@
1
- # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
3
- require "ruby-asterisk/version"
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
4
+ require 'ruby-asterisk/version'
4
5
 
5
6
  Gem::Specification.new do |s|
6
- s.name = "ruby-asterisk"
7
- s.version = Rami::VERSION
7
+ s.name = 'ruby-asterisk'
8
+ s.version = RubyAsterisk::VERSION
8
9
  s.platform = Gem::Platform::RUBY
9
- s.authors = ["Emiliano Della Casa"]
10
- s.email = ["emiliano.dellacasa@gmail.com"]
11
- s.homepage = "http://github.com/emilianodellacasa/ruby-asterisk"
12
- s.summary = %q{Asterisk Manager Interface in Ruby}
13
- s.description = %q{Add support to your ruby or rails projects to Asterisk Manager Interface (AMI)}
10
+ s.authors = ['Emiliano Della Casa']
11
+ s.email = ['emiliano.dellacasa@gmail.com']
12
+ s.homepage = 'http://github.com/emilianodellacasa/ruby-asterisk'
13
+ s.summary = 'Asterisk Manager Interface in Ruby'
14
+ s.description = 'Add support to your ruby or rails projects to Asterisk Manager Interface (AMI)'
14
15
  s.licenses = ['MIT']
15
-
16
- s.rubyforge_project = "ruby-asterisk"
16
+ s.required_ruby_version = '>= 3.1'
17
17
 
18
18
  s.files = `git ls-files`.split("\n")
19
- s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
20
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
21
- s.require_paths = ["lib"]
19
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
20
+ s.require_paths = ['lib']
21
+
22
+ s.add_runtime_dependency 'async', '~> 2.0'
23
+ s.add_runtime_dependency 'faraday', '>= 1.0'
24
+ s.add_runtime_dependency 'logger'
25
+ s.add_runtime_dependency 'websocket-driver', '~> 0.8'
22
26
 
23
- s.add_development_dependency "rake"
27
+ s.add_development_dependency 'rake'
24
28
  s.add_development_dependency 'rspec'
29
+ s.add_development_dependency 'rubocop'
30
+ s.add_development_dependency 'rubocop-performance'
25
31
  s.add_development_dependency 'simplecov'
26
- s.add_development_dependency 'quality'
32
+ s.metadata['rubygems_mfa_required'] = 'true'
27
33
  end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ruby-asterisk/agi/protocol'
5
+
6
+ RSpec.describe RubyAsterisk::AGI::Protocol do
7
+ describe '.parse_response' do
8
+ subject(:parse) { described_class.parse_response(line) }
9
+
10
+ context 'with a plain success result' do
11
+ let(:line) { '200 result=1' }
12
+
13
+ it { expect(parse).to eq(code: 200, result: '1', data: nil) }
14
+ end
15
+
16
+ context 'with result=0' do
17
+ let(:line) { '200 result=0' }
18
+
19
+ it { expect(parse).to eq(code: 200, result: '0', data: nil) }
20
+ end
21
+
22
+ context 'with endpos extra data' do
23
+ let(:line) { '200 result=0 endpos=12345' }
24
+
25
+ it { expect(parse).to eq(code: 200, result: '0', data: 'endpos=12345') }
26
+ end
27
+
28
+ context 'with parenthetical extra data' do
29
+ let(:line) { '200 result=1 (some text)' }
30
+
31
+ it { expect(parse).to eq(code: 200, result: '1', data: 'some text') }
32
+ end
33
+
34
+ context 'with trailing newline' do
35
+ let(:line) { "200 result=1\n" }
36
+
37
+ it { expect(parse[:code]).to eq(200) }
38
+ it { expect(parse[:result]).to eq('1') }
39
+ end
40
+
41
+ context 'with 510 error' do
42
+ let(:line) { '510 Invalid or unknown command' }
43
+
44
+ it { expect(parse).to eq(code: 510, result: nil, data: 'Invalid or unknown command') }
45
+ end
46
+
47
+ context 'with 511 error' do
48
+ let(:line) { '511 Command Not Permitted on a dead channel' }
49
+
50
+ it { expect(parse).to eq(code: 511, result: nil, data: 'Command Not Permitted on a dead channel') }
51
+ end
52
+
53
+ context 'with 520 single-line syntax error' do
54
+ let(:line) { '520 Invalid command syntax' }
55
+
56
+ it { expect(parse).to eq(code: 520, result: nil, data: 'Invalid command syntax') }
57
+ end
58
+
59
+ context 'with nil (socket EOF)' do
60
+ let(:line) { nil }
61
+
62
+ it { expect(parse).to eq(code: 0, result: nil, data: 'Connection closed') }
63
+ end
64
+
65
+ context 'with an unrecognised line' do
66
+ let(:line) { 'INVALID' }
67
+
68
+ it { expect(parse[:code]).to eq(0) }
69
+ end
70
+ end
71
+
72
+ describe '.format_command' do
73
+ it 'formats a bare command with no args' do
74
+ expect(described_class.format_command('ANSWER')).to eq("ANSWER\n")
75
+ end
76
+
77
+ it 'appends plain args without quoting' do
78
+ expect(described_class.format_command('WAIT FOR DIGIT', '5000')).to eq("WAIT FOR DIGIT 5000\n")
79
+ end
80
+
81
+ it 'wraps args that contain spaces in double quotes' do
82
+ expect(described_class.format_command('VERBOSE', 'hello world', '1')).to eq(%(VERBOSE "hello world" 1\n))
83
+ end
84
+
85
+ it 'escapes backslashes inside quoted args' do
86
+ expect(described_class.format_command('EXEC', 'foo', 'a\\b')).to eq(%(EXEC foo "a\\\\b"\n))
87
+ end
88
+
89
+ it 'escapes double quotes inside quoted args' do
90
+ expect(described_class.format_command('EXEC', 'foo', 'say "hi"')).to eq(%(EXEC foo "say \\"hi\\""\n))
91
+ end
92
+ end
93
+
94
+ describe '.escape_argument' do
95
+ it 'returns "" for an empty string' do
96
+ expect(described_class.escape_argument('')).to eq('""')
97
+ end
98
+
99
+ it 'returns the arg unchanged when no special chars' do
100
+ expect(described_class.escape_argument('hello-world')).to eq('hello-world')
101
+ end
102
+
103
+ it 'wraps in quotes when arg contains a space' do
104
+ expect(described_class.escape_argument('hello world')).to eq('"hello world"')
105
+ end
106
+
107
+ it 'escapes an internal backslash' do
108
+ expect(described_class.escape_argument('a\\b')).to eq('"a\\\\b"')
109
+ end
110
+
111
+ it 'escapes an internal double-quote' do
112
+ expect(described_class.escape_argument('say "hi"')).to eq('"say \\"hi\\""')
113
+ end
114
+ end
115
+
116
+ describe '.quote' do
117
+ it 'always wraps in double quotes even for plain strings' do
118
+ expect(described_class.quote('hello-world')).to eq('"hello-world"')
119
+ end
120
+
121
+ it 'wraps an empty string' do
122
+ expect(described_class.quote('')).to eq('""')
123
+ end
124
+
125
+ it 'escapes internal double quotes' do
126
+ expect(described_class.quote('say "hi"')).to eq('"say \\"hi\\""')
127
+ end
128
+
129
+ it 'escapes internal backslashes' do
130
+ expect(described_class.quote('a\\b')).to eq('"a\\\\b"')
131
+ end
132
+ end
133
+
134
+ describe '.parse_env_line' do
135
+ it 'returns [key, value] for a valid agi_ line' do
136
+ expect(described_class.parse_env_line('agi_channel: SIP/test-1')).to eq(['agi_channel', 'SIP/test-1'])
137
+ end
138
+
139
+ it 'strips leading/trailing whitespace from the value' do
140
+ expect(described_class.parse_env_line('agi_request: agi://localhost ')).to eq(['agi_request', 'agi://localhost'])
141
+ end
142
+
143
+ it 'returns nil for a non-agi line' do
144
+ expect(described_class.parse_env_line('unexpected garbage')).to be_nil
145
+ end
146
+
147
+ it 'returns nil for a blank line' do
148
+ expect(described_class.parse_env_line('')).to be_nil
149
+ end
150
+ end
151
+
152
+ describe '.parse_env_block' do
153
+ require 'stringio'
154
+
155
+ it 'parses multiple agi_* lines into a hash' do
156
+ io = StringIO.new("agi_channel: SIP/1001\nagi_callerid: 555\n\n")
157
+ env = described_class.parse_env_block(io)
158
+
159
+ expect(env).to eq('agi_channel' => 'SIP/1001', 'agi_callerid' => '555')
160
+ end
161
+
162
+ it 'stops at the blank line and ignores content after it' do
163
+ io = StringIO.new("agi_channel: SIP/1001\n\nagi_after_blank: ignored\n")
164
+ env = described_class.parse_env_block(io)
165
+
166
+ expect(env).not_to have_key('agi_after_blank')
167
+ end
168
+
169
+ it 'yields unrecognised lines to the block' do
170
+ io = StringIO.new("agi_channel: SIP/1001\nunknown line\n\n")
171
+ unrecognised = []
172
+ described_class.parse_env_block(io) { |l| unrecognised << l }
173
+
174
+ expect(unrecognised).to eq(['unknown line'])
175
+ expect(unrecognised.length).to eq(1)
176
+ end
177
+
178
+ it 'handles EOF gracefully (no blank line terminator)' do
179
+ io = StringIO.new("agi_channel: SIP/1001\n")
180
+ env = described_class.parse_env_block(io)
181
+
182
+ expect(env).to eq('agi_channel' => 'SIP/1001')
183
+ end
184
+ end
185
+
186
+ describe '.collect_multiline_error' do
187
+ require 'stringio'
188
+
189
+ let(:base_response) { { code: 520, result: nil, data: nil } }
190
+
191
+ it 'returns first_response unchanged when first_line has no continuation marker' do
192
+ io = StringIO.new('')
193
+ result = described_class.collect_multiline_error('520 Invalid command syntax', base_response, io)
194
+
195
+ expect(result).to equal(base_response)
196
+ end
197
+
198
+ it 'reads continuation lines until the closing NNN line and joins them' do
199
+ continuation = "520-Usage: WAIT FOR DIGIT <timeout>\n520 End of proper usage.\n"
200
+ io = StringIO.new(continuation)
201
+ result = described_class.collect_multiline_error('520-Invalid command syntax.', base_response, io)
202
+
203
+ expect(result[:code]).to eq(520)
204
+ expect(result[:data]).to include('Invalid command syntax')
205
+ expect(result[:data]).to include('WAIT FOR DIGIT')
206
+ end
207
+
208
+ it 'preserves the code from first_response' do
209
+ continuation = "520-Details\n520 End of proper usage.\n"
210
+ io = StringIO.new(continuation)
211
+ result = described_class.collect_multiline_error('520-Intro', { code: 520, result: nil, data: nil }, io)
212
+
213
+ expect(result[:code]).to eq(520)
214
+ end
215
+
216
+ it 'returns a frozen hash' do
217
+ continuation = "520-Usage line\n520 End of proper usage.\n"
218
+ io = StringIO.new(continuation)
219
+ result = described_class.collect_multiline_error('520-Intro', base_response, io)
220
+
221
+ expect(result).to be_frozen
222
+ end
223
+ end
224
+
225
+ describe '.error?' do
226
+ it 'returns true for 5xx codes' do
227
+ expect(described_class.error?(code: 510, result: nil, data: 'x')).to be(true)
228
+ expect(described_class.error?(code: 520, result: nil, data: 'x')).to be(true)
229
+ end
230
+
231
+ it 'returns false for 200' do
232
+ expect(described_class.error?(code: 200, result: '1', data: nil)).to be(false)
233
+ end
234
+
235
+ it 'returns false for code 0' do
236
+ expect(described_class.error?(code: 0, result: nil, data: 'x')).to be(false)
237
+ end
238
+ end
239
+ end