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,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
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ruby-asterisk/ami/parser'
5
+
6
+ RSpec.describe RubyAsterisk::AMI::Parser do
7
+ # Helper: push chunks into a buffer, drain, collect yielded messages.
8
+ def drain_chunks(*chunks)
9
+ buffer = +''
10
+ msgs = []
11
+ chunks.each do |chunk|
12
+ buffer << chunk
13
+ described_class.drain(buffer) { |m| msgs << m }
14
+ end
15
+ msgs
16
+ end
17
+
18
+ # ── parse_headers ───────────────────────────────────────────────────────────
19
+
20
+ describe '.parse_headers' do
21
+ it 'parses key-value lines' do
22
+ raw = "Response: Success\r\nActionID: abc\r\nMessage: OK\r\n"
23
+ h = described_class.parse_headers(raw)
24
+ expect(h['Response']).to eq('Success')
25
+ expect(h['ActionID']).to eq('abc')
26
+ expect(h['Message']).to eq('OK')
27
+ end
28
+
29
+ it 'trims leading/trailing whitespace from values' do
30
+ h = described_class.parse_headers("Response: Success\r\nMessage: hello world \r\n")
31
+ expect(h['Message']).to eq('hello world')
32
+ end
33
+
34
+ it 'preserves colons inside values' do
35
+ h = described_class.parse_headers("Event: NewExten\r\nAppData: SIP/101:60\r\n")
36
+ expect(h['AppData']).to eq('SIP/101:60')
37
+ end
38
+
39
+ it 'skips lines without a colon separator (e.g. AMI welcome banner)' do
40
+ h = described_class.parse_headers("Asterisk Call Manager/1.1\nResponse: Success\r\n")
41
+ expect(h.keys).to eq(['Response'])
42
+ end
43
+
44
+ it 'returns a frozen hash with frozen keys and values' do
45
+ h = described_class.parse_headers("Response: OK\r\nActionID: x\r\n")
46
+ expect(h).to be_frozen
47
+ h.each_key { |k| expect(k).to be_frozen }
48
+ h.each_value { |v| expect(v).to be_frozen }
49
+ end
50
+ end
51
+
52
+ # ── drain / split-packet reassembly ─────────────────────────────────────────
53
+
54
+ describe '.drain — split-packet reassembly' do
55
+ it 'assembles a Response split across two chunks' do
56
+ msgs = drain_chunks(
57
+ "Response: Success\r\nActionID: abc123\r\n",
58
+ "Message: OK\r\n\r\n"
59
+ )
60
+ expect(msgs.size).to eq(1)
61
+ expect(msgs.first[:type]).to eq(:response)
62
+ expect(msgs.first[:action_id]).to eq('abc123')
63
+ expect(msgs.first[:headers]['Message']).to eq('OK')
64
+ end
65
+
66
+ it 'assembles an Event split across three chunks' do
67
+ msgs = drain_chunks(
68
+ 'Event: Han',
69
+ "gup\r\nChannel: SIP/",
70
+ "101\r\n\r\n"
71
+ )
72
+ expect(msgs.size).to eq(1)
73
+ expect(msgs.first[:type]).to eq(:event)
74
+ expect(msgs.first[:event].name).to eq('Hangup')
75
+ expect(msgs.first[:event].headers['Channel']).to eq('SIP/101')
76
+ end
77
+
78
+ it 'emits nothing until the delimiter arrives' do
79
+ msgs = drain_chunks("Response: Success\r\nActionID: xyz\r\n")
80
+ expect(msgs).to be_empty
81
+ end
82
+
83
+ it 'emits the message once the delimiter arrives' do
84
+ msgs = drain_chunks(
85
+ "Response: Success\r\nActionID: xyz\r\n",
86
+ "\r\n"
87
+ )
88
+ expect(msgs.size).to eq(1)
89
+ end
90
+ end
91
+
92
+ # ── multiple messages in one chunk ─────────────────────────────────────────
93
+
94
+ describe '.drain — multiple messages in one chunk' do
95
+ it 'emits all messages when two complete messages arrive together' do
96
+ two_msgs =
97
+ "Response: Success\r\nActionID: r1\r\n\r\n" \
98
+ "Event: FullyBooted\r\nPrivilege: system,all\r\nStatus: Fully Booted\r\n\r\n"
99
+
100
+ msgs = drain_chunks(two_msgs)
101
+ expect(msgs.size).to eq(2)
102
+ expect(msgs[0][:type]).to eq(:response)
103
+ expect(msgs[0][:action_id]).to eq('r1')
104
+ expect(msgs[1][:type]).to eq(:event)
105
+ expect(msgs[1][:event].name).to eq('FullyBooted')
106
+ end
107
+
108
+ it 'handles ten back-to-back events in a single chunk' do
109
+ chunk = (1..10).map { |i| "Event: TestEvent\r\nSequence: #{i}\r\n\r\n" }.join
110
+ msgs = drain_chunks(chunk)
111
+ expect(msgs.size).to eq(10)
112
+ sequences = msgs.map { |m| m[:event].headers['Sequence'].to_i }
113
+ expect(sequences).to eq((1..10).to_a)
114
+ end
115
+ end
116
+
117
+ # ── Response routing ────────────────────────────────────────────────────────
118
+
119
+ describe 'response routing' do
120
+ it 'sets action_id from the ActionID header' do
121
+ msgs = drain_chunks("Response: Success\r\nActionID: myid\r\n\r\n")
122
+ expect(msgs.first[:action_id]).to eq('myid')
123
+ end
124
+
125
+ it 'sets action_id to nil when no ActionID header is present' do
126
+ msgs = drain_chunks("Response: Follows\r\nPrivilege: Command\r\n\r\n")
127
+ expect(msgs.first[:type]).to eq(:response)
128
+ expect(msgs.first[:action_id]).to be_nil
129
+ end
130
+
131
+ it 'exposes a frozen raw string' do
132
+ msgs = drain_chunks("Response: Error\r\nActionID: f2\r\n\r\n")
133
+ expect(msgs.first[:raw]).to be_frozen
134
+ end
135
+ end
136
+
137
+ # ── Event routing ───────────────────────────────────────────────────────────
138
+
139
+ describe 'event routing' do
140
+ it 'creates a frozen Event object with the correct name and headers' do
141
+ msgs = drain_chunks("Event: Hangup\r\nChannel: SIP/200\r\nCause: 16\r\n\r\n")
142
+ event = msgs.first[:event]
143
+ expect(event).to be_a(RubyAsterisk::AMI::Event)
144
+ expect(event).to be_frozen
145
+ expect(event.name).to eq('Hangup')
146
+ expect(event.headers['Cause']).to eq('16')
147
+ end
148
+
149
+ it 'Event object exposes the raw AMI message string' do
150
+ raw_input = "Event: Hangup\r\nChannel: SIP/200\r\n\r\n"
151
+ msgs = drain_chunks(raw_input)
152
+ expect(msgs.first[:event].raw).to eq(raw_input)
153
+ end
154
+ end
155
+
156
+ # ── AMI welcome banner tolerance ────────────────────────────────────────────
157
+
158
+ describe 'AMI welcome banner tolerance' do
159
+ it 'ignores the banner and still parses the subsequent response' do
160
+ combined =
161
+ "Asterisk Call Manager/1.1\n" \
162
+ "Response: Success\r\nActionID: banner_test\r\n\r\n"
163
+
164
+ msgs = drain_chunks(combined)
165
+ expect(msgs.size).to eq(1)
166
+ expect(msgs.first[:type]).to eq(:response)
167
+ expect(msgs.first[:action_id]).to eq('banner_test')
168
+ end
169
+ end
170
+
171
+ # ── build_message ────────────────────────────────────────────────────────────
172
+
173
+ describe '.build_message' do
174
+ it 'returns nil for frames with no recognised Response/Event header' do
175
+ expect(described_class.build_message("Asterisk Call Manager/1.1\r\n\r\n")).to be_nil
176
+ end
177
+
178
+ it 'returns a frozen response hash' do
179
+ msg = described_class.build_message("Response: OK\r\nActionID: x\r\n\r\n")
180
+ expect(msg).to be_frozen
181
+ expect(msg[:type]).to eq(:response)
182
+ end
183
+
184
+ it 'returns a frozen event hash' do
185
+ msg = described_class.build_message("Event: Hangup\r\n\r\n")
186
+ expect(msg).to be_frozen
187
+ expect(msg[:type]).to eq(:event)
188
+ end
189
+ end
190
+ end