test_webhook_handler.rb

  1# frozen_string_literal: true
  2
  3require "test_helper"
  4require_relative "../sgx-bwmsgsv2"
  5
  6def panic(e)
  7	$panic = e
  8end
  9
 10class WebhookHandlerTest < Minitest::Test
 11	def setup
 12		reset_stanzas!
 13		reset_redis!
 14	end
 15
 16	def invoke_webhook(payload, extra_env: {})
 17		with_stubs([
 18			[
 19				SGXbwmsgsv2,
 20				:write,
 21				->(data) { SGXbwmsgsv2.write_to_stream(data) }
 22			]
 23		]) do
 24			handler = WebhookHandler.new
 25			env = {
 26				"REQUEST_URI" => "/",
 27				"REQUEST_METHOD" => "POST",
 28				"params" => {"_json" => [payload]}
 29			}.merge(extra_env)
 30			handler.instance_variable_set(:@env, env)
 31			def handler.params
 32				@env["params"]
 33			end
 34
 35			EMPromise.resolve(nil).then {
 36				handler.response(env)
 37			}.sync
 38		end
 39	end
 40
 41	def test_inbound_sms_emits_to_stream
 42		payload = {
 43			"type" => "message-received",
 44			"to" => "+15550000000",
 45			"message" => {
 46				"id" => "bw-in-123",
 47				"direction" => "in",
 48				"owner" => "+15550000000",
 49				"from" => "+15551234567",
 50				"to" => ["+15550000000"],
 51				"time" => "2025-01-13T10:00:00Z",
 52				"text" => "Hello from outside"
 53			}
 54		}
 55
 56		invoke_webhook(payload)
 57
 58		entries = REDIS.stream_entries("messages").sync
 59		assert_equal 1, entries.length
 60
 61		event = entries.first[:fields]
 62		assert_equal "in", event["event"]
 63		assert_equal "+15551234567", event["from"]
 64		assert_equal JSON.dump(["+15550000000"]), event["to"]
 65		assert_equal "bw-in-123", event["bandwidth_id"]
 66		assert_equal "+15550000000", event["owner"]
 67		assert_equal "Hello from outside", event["body"]
 68		assert_equal JSON.dump([]), event["media_urls"]
 69	end
 70	em :test_inbound_sms_emits_to_stream
 71
 72	def test_inbound_mms_emits_to_stream_and_filters_smil
 73		payload = {
 74			"type" => "message-received",
 75			"to" => "+15550000000",
 76			"message" => {
 77				"id" => "bw-mms-456",
 78				"direction" => "in",
 79				"owner" => "+15550000000",
 80				"from" => "+15551234567",
 81				"to" => ["+15550000000"],
 82				"time" => "2025-01-13T10:05:00Z",
 83				"text" => "Check this out",
 84				"media" => [
 85					"https://example.com/image.jpg",
 86					"https://example.com/file.smil",
 87					"https://example.com/data.txt",
 88					"https://example.com/meta.xml"
 89				]
 90			}
 91		}
 92
 93		invoke_webhook(payload)
 94
 95		entries = REDIS.stream_entries("messages").sync
 96		assert_equal 1, entries.length
 97
 98		event = entries.first[:fields]
 99		assert_equal "in", event["event"]
100		assert_equal JSON.dump(["https://example.com/image.jpg"]), event["media_urls"]
101	end
102	em :test_inbound_mms_emits_to_stream_and_filters_smil
103
104	def test_message_delivered_emits_to_stream
105		payload = {
106			"type" => "message-delivered",
107			"to" => "+15550000000",
108			"message" => {
109				"id" => "bw-out-789",
110				"direction" => "out",
111				"owner" => "+15550000000",
112				"from" => "+15550000000",
113				"to" => ["+15551234567"],
114				"time" => "2025-01-13T10:10:00Z",
115				"tag" => "stanza-id-abc extra-data"
116			}
117		}
118
119		invoke_webhook(payload)
120
121		entries = REDIS.stream_entries("messages").sync
122		assert_equal 1, entries.length
123
124		event = entries.first[:fields]
125		assert_equal "delivered", event["event"]
126		assert_equal "stanza-id-abc", event["stanza_id"]
127		assert_equal "bw-out-789", event["bandwidth_id"]
128		assert_equal "2025-01-13T10:10:00Z", event["timestamp"]
129	end
130	em :test_message_delivered_emits_to_stream
131
132	def test_message_failed_emits_to_stream
133		payload = {
134			"type" => "message-failed",
135			"to" => "+15551234567",
136			"message" => {
137				"id" => "bw-out-999",
138				"direction" => "out",
139				"owner" => "+15550000000",
140				"from" => "+15550000000",
141				"to" => ["+15551234567"],
142				"time" => "2025-01-13T10:15:00Z",
143				"tag" => "failed-stanza-xyz extra",
144				"errorCode" => 4720,
145				"description" => "Carrier rejected message"
146			}
147		}
148
149		invoke_webhook(payload)
150
151		entries = REDIS.stream_entries("messages").sync
152		assert_equal 1, entries.length
153
154		event = entries.first[:fields]
155		assert_equal "failed", event["event"]
156		assert_equal "failed-stanza-xyz", event["stanza_id"]
157		assert_equal "bw-out-999", event["bandwidth_id"]
158		assert_equal "4720", event["error_code"]
159		assert_equal "Carrier rejected message", event["error_description"]
160		assert_equal "2025-01-13T10:15:00Z", event["timestamp"]
161	end
162	em :test_message_failed_emits_to_stream
163
164	def test_resend_emits_resend_event_instead_of_in
165		payload = {
166			"type" => "message-received",
167			"message" => {
168				"id" => "bw-in-resend-001",
169				"direction" => "in",
170				"owner" => "+15550000000",
171				"from" => "+15551234567",
172				"to" => ["+15550000000"],
173				"time" => "2025-01-13T10:00:00Z",
174				"text" => "Resent message"
175			}
176		}
177
178		invoke_webhook(
179			payload,
180			extra_env: { "HTTP_X_JMP_RESEND_OF" => "1736762400000-0" }
181		)
182
183		entries = REDIS.stream_entries("messages").sync
184		assert_equal 1, entries.length
185
186		event = entries.first[:fields]
187		assert_equal "resend", event["event"]
188		assert_equal "bwmsgsv2", event["source"]
189		assert_equal "+15550000000", event["owner"]
190		assert_equal "1736762400000-0", event["original_stream_id"]
191		assert_equal "bw-in-resend-001", event["original_bandwidth_id"]
192		refute event.key?("from"), "Resend events should not duplicate message fields"
193		refute event.key?("body"), "Resend events should not duplicate message fields"
194	end
195	em :test_resend_emits_resend_event_instead_of_in
196
197	def test_message_received_empty_params_writes_no_stanza
198		result = invoke_webhook(nil, extra_env: { "params" => {} })
199		assert_equal [200, {}, "OK"], result
200		assert_empty written
201		entries = REDIS.stream_entries("messages").sync
202		assert_equal 0, entries.length
203	end
204	em :test_message_received_empty_params_writes_no_stanza
205
206	def test_message_received_unregistered_jid_writes_no_stanza
207		payload = {
208			"type" => "message-received",
209			"to" => "+15559999999",
210			"message" => {
211				"id" => "bw-unreg-001",
212				"direction" => "in",
213				"owner" => "+15559999999",
214				"from" => "+15551234567",
215				"to" => ["+15559999999"],
216				"time" => "2025-01-13T10:00:00Z",
217				"text" => "Hello"
218			}
219		}
220
221		result = invoke_webhook(payload)
222		assert_equal [403, {}, "Customer not found\n"], result
223		assert_empty written
224		entries = REDIS.stream_entries("messages").sync
225		assert_equal 0, entries.length
226	end
227	em :test_message_received_unregistered_jid_writes_no_stanza
228
229	def test_message_received_single_recipient_text_stanza
230		payload = {
231			"type" => "message-received",
232			"to" => "+15550000000",
233			"message" => {
234				"id" => "bw-in-txt-001",
235				"direction" => "in",
236				"owner" => "+15550000000",
237				"from" => "+15551234567",
238				"to" => ["+15550000000"],
239				"time" => "2025-01-13T10:00:00Z",
240				"text" => "Hello from outside"
241			}
242		}
243
244		invoke_webhook(payload)
245
246		assert_equal 1, written.length
247		msg = written.first
248		assert_instance_of Blather::Stanza::Message, msg
249		assert_equal "test@example.com", msg.to.to_s
250		assert_equal "+15551234567@component", msg.from.to_s
251		assert_equal "Hello from outside", msg.body
252		refute msg.find_first("ns:x", ns: "jabber:x:oob")
253		refute msg.find_first(
254			"ns:addresses",
255			ns: "http://jabber.org/protocol/address"
256		)
257	end
258	em :test_message_received_single_recipient_text_stanza
259
260	def test_message_received_zero_recipients_writes_no_stanza
261		payload = {
262			"type" => "message-received",
263			"to" => "+15550000000",
264			"message" => {
265				"id" => "bw-in-zero-001",
266				"direction" => "in",
267				"owner" => "+15550000000",
268				"from" => "+15551234567",
269				"to" => [],
270				"time" => "2025-01-13T10:00:00Z",
271				"text" => "Hello with empty to"
272			}
273		}
274
275		result = invoke_webhook(payload)
276		assert_equal [400, {}, "Missing params\n"], result
277		assert_empty written
278		entries = REDIS.stream_entries("messages").sync
279		assert_equal 0, entries.length
280	end
281	em :test_message_received_zero_recipients_writes_no_stanza
282
283	def test_message_received_group_stanza_has_addresses
284		payload = {
285			"type" => "message-received",
286			"to" => "+15550000000",
287			"message" => {
288				"id" => "bw-in-grp-001",
289				"direction" => "in",
290				"owner" => "+15550000000",
291				"from" => "+15551234567",
292				"to" => ["+15550000000", "+15559999999"],
293				"time" => "2025-01-13T10:00:00Z",
294				"text" => "Hello group"
295			}
296		}
297
298		invoke_webhook(payload)
299
300		assert_equal 1, written.length
301		msg = written.first
302		assert_equal "example.com", msg.to.to_s
303		assert_equal "+15551234567@component", msg.from.to_s
304		assert_equal "Hello group", msg.body
305
306		addrs = msg.find_first("addresses")
307		refute_nil addrs
308		assert_equal(
309			"http://jabber.org/protocol/address", addrs['xmlns']
310		)
311
312		address_nodes = addrs.find("address")
313		assert_equal 2, address_nodes.length
314
315		jid_addr = address_nodes.detect { |a| a['jid'] }
316		assert_equal "to", jid_addr['type']
317		assert_equal "test@example.com", jid_addr['jid']
318
319		uri_addr = address_nodes.detect { |a| a['uri'] }
320		assert_equal "to", uri_addr['type']
321		assert_equal "sms:+15559999999", uri_addr['uri']
322		assert_equal "true", uri_addr['delivered']
323	end
324	em :test_message_received_group_stanza_has_addresses
325
326	def test_message_received_single_recipient_with_media_stanza
327		payload = {
328			"type" => "message-received",
329			"to" => "+15550000000",
330			"message" => {
331				"id" => "bw-in-media-001",
332				"direction" => "in",
333				"owner" => "+15550000000",
334				"from" => "+15551234567",
335				"to" => ["+15550000000"],
336				"time" => "2025-01-13T10:00:00Z",
337				"text" => "Check this",
338				"media" => ["https://example.com/image.jpg"]
339			}
340		}
341
342		invoke_webhook(payload)
343
344		assert_equal 2, written.length
345
346		oob_msg = written[0]
347		assert_equal "test@example.com", oob_msg.to.to_s
348		assert_equal "+15551234567@component", oob_msg.from.to_s
349		oob_x = oob_msg.find_first("x")
350		refute_nil oob_x
351		assert_equal "jabber:x:oob", oob_x['xmlns']
352		oob_url = oob_x.find_first("url")
353		refute_nil oob_url
354		assert_equal "https://example.com/image.jpg", oob_url.text
355
356		text_msg = written[1]
357		assert_equal "test@example.com", text_msg.to.to_s
358		assert_equal "+15551234567@component", text_msg.from.to_s
359		assert_equal "Check this", text_msg.body
360	end
361	em :test_message_received_single_recipient_with_media_stanza
362
363	def test_message_received_empty_body_no_media_returns_400
364		payload = {
365			"type" => "message-received",
366			"to" => "+15550000000",
367			"message" => {
368				"id" => "bw-in-empty-001",
369				"direction" => "in",
370				"owner" => "+15550000000",
371				"from" => "+15551234567",
372				"to" => ["+15550000000"],
373				"time" => "2025-01-13T10:00:00Z",
374				"text" => ""
375			}
376		}
377
378		result = invoke_webhook(payload)
379		assert_equal [400, {}, "Missing params\n"], result
380		assert_empty written
381		entries = REDIS.stream_entries("messages").sync
382		assert_equal 0, entries.length
383	end
384	em :test_message_received_empty_body_no_media_returns_400
385
386	def test_message_received_missing_body_no_media_returns_400
387		payload = {
388			"type" => "message-received",
389			"to" => "+15550000000",
390			"message" => {
391				"id" => "bw-in-nil-001",
392				"direction" => "in",
393				"owner" => "+15550000000",
394				"from" => "+15551234567",
395				"to" => ["+15550000000"],
396				"time" => "2025-01-13T10:00:00Z"
397			}
398		}
399
400		result = invoke_webhook(payload)
401		assert_equal [400, {}, "Missing params\n"], result
402		assert_empty written
403		entries = REDIS.stream_entries("messages").sync
404		assert_equal 0, entries.length
405	end
406	em :test_message_received_missing_body_no_media_returns_400
407end