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
data/lib/ruby-asterisk.rb CHANGED
@@ -1,148 +1,6 @@
1
- require "ruby-asterisk/version"
2
- require "ruby-asterisk/request"
3
- require "ruby-asterisk/response"
1
+ # frozen_string_literal: true
4
2
 
5
- require 'net/telnet'
6
-
7
- module RubyAsterisk
8
- class AMI
9
- attr_accessor :host, :port, :connected
10
-
11
- def initialize(host,port)
12
- self.host = host.to_s
13
- self.port = port.to_i
14
- self.connected = false
15
- @session = nil
16
- end
17
-
18
- def connect
19
- begin
20
- @session = Net::Telnet::new("Host" => self.host,"Port" => self.port)
21
- self.connected = true
22
- rescue Exception => ex
23
- false
24
- end
25
- end
26
-
27
- def disconnect
28
- begin
29
- @session.close if self.connected
30
- @session = nil
31
- self.connected = false
32
- true
33
- rescue Exception => ex
34
- puts ex
35
- false
36
- end
37
- end
38
-
39
- def login(username,password)
40
- self.connect unless self.connected
41
- execute "Login", {"Username" => username, "Secret" => password, "Event" => "On"}
42
- end
43
-
44
- def command(command)
45
- execute "Command", {"Command" => command}
46
- end
47
-
48
- def core_show_channels
49
- execute "CoreShowChannels"
50
- end
51
-
52
- def meet_me_list
53
- execute "MeetMeList"
54
- end
55
-
56
- def parked_calls
57
- execute "ParkedCalls"
58
- end
59
-
60
- def extension_state(exten, context, action_id=nil)
61
- execute "ExtensionState", {"Exten" => exten, "Context" => context, "ActionID" => action_id}
62
- end
63
-
64
- def skinny_devices
65
- execute "SKINNYdevices"
66
- end
67
-
68
- def skinny_lines
69
- execute "SKINNYlines"
70
- end
71
-
72
- def status(channel=nil,action_id=nil)
73
- execute "Status", {"Channel" => channel, "ActionID" => action_id}
74
- end
75
-
76
- def originate(caller,context,callee,priority,variable=nil)
77
- execute "Originate", {"Channel" => caller, "Context" => context, "Exten" => callee, "Priority" => priority, "Callerid" => caller, "Timeout" => "30000", "Variable" => variable }
78
- end
79
-
80
- def channels
81
- execute "Command", { "Command" => "show channels" }
82
- end
83
-
84
- def redirect(caller,context,callee,priority,variable=nil)
85
- execute "Redirect", {"Channel" => caller, "Context" => context, "Exten" => callee, "Priority" => priority, "Callerid" => caller, "Timeout" => "30000", "Variable" => variable}
86
- end
87
-
88
- def queues
89
- execute "Queues", {}
90
- end
91
-
92
- def queue_add(queue, exten, penalty=2, paused=false, member_name)
93
- execute "QueueAdd", {"Queue" => queue, "Interface" => exten, "Penalty" => penalty, "Paused" => paused, "MemberName" => member_name}
94
- end
95
-
96
- def queue_pause(queue, exten)
97
- execute "QueuePause", {"Interface" => exten, "Paused" => paused}
98
- end
99
-
100
- def queue_remove(queue, exten)
101
- execute "QueueRemove", {"Queue" => queue, "Interface" => exten}
102
- end
103
-
104
- def queue_status
105
- execute "QueueStatus"
106
- end
107
-
108
- def queue_summary(queue)
109
- execute "QueueSummary", {"Queue" => queue}
110
- end
111
-
112
- def mailbox_status(exten, context="default")
113
- execute "MailboxStatus", {"Mailbox" => "#{exten}@#{context}"}
114
- end
115
-
116
- def mailbox_count(exten, context="default")
117
- execute "MailboxCount", {"Mailbox" => "#{exten}@#{context}"}
118
- end
119
-
120
- def queue_pause(interface,paused,queue,reason='none')
121
- execute "QueuePause", {"Interface" => interface, "Paused" => paused, "Queue" => queue, "Reason" => reason}
122
- end
123
-
124
- def ping
125
- execute "Ping"
126
- end
127
-
128
- def event_mask(event_mask="off")
129
- execute "Events", {"EventMask" => event_mask}
130
- end
131
-
132
- def sip_peers
133
- execute "SIPpeers"
134
- end
135
-
136
- private
137
- def execute(command, options={})
138
- request = Request.new(command, options)
139
- request.commands.each do |command|
140
- @session.write(command)
141
- end
142
- @session.waitfor("Match" => /ActionID: #{request.action_id}.*?\n\n/m, "Timeout" => 10) do |data|
143
- request.response_data << data
144
- end
145
- Response.new(command,request.response_data)
146
- end
147
- end
148
- 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,24 +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 = ["e.dellacasa@engim.eu"]
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)}
14
-
15
- s.rubyforge_project = "ruby-asterisk"
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)'
15
+ s.licenses = ['MIT']
16
+ s.required_ruby_version = '>= 3.1'
16
17
 
17
18
  s.files = `git ls-files`.split("\n")
18
- s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
- 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'
21
26
 
22
- s.add_development_dependency "rake"
23
- s.add_development_dependency 'rspec'
27
+ s.add_development_dependency 'rake'
28
+ s.add_development_dependency 'rspec'
29
+ s.add_development_dependency 'rubocop'
30
+ s.add_development_dependency 'rubocop-performance'
31
+ s.add_development_dependency 'simplecov'
32
+ s.metadata['rubygems_mfa_required'] = 'true'
24
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
@@ -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