Track Call Reachability

Christopher Vollick created

This intercepts calls that are from the reachability numbers and checks
their status.

I had to extend the reachability repo a bit because I realized that
unlike texts which I just want to do nothing when they're intercepted,
for calls I actually have to _return_ something in that case.

Due to my short-circuit none of the existing tests needed to be changed,
because the calls weren't coming from a reachability number. But then I
added two more tests, one to make sure a call from the reachability
number works normally if reachability is not being tested for that user,
and another that verifies that if we're testing reachability it hangs up
and increments the counter, which is the actual point of this patch.

Change summary

lib/reachability_repo.rb |  6 +-
test/test_helper.rb      |  7 +++
test/test_web.rb         | 95 ++++++++++++++++++++++++++++++++++++++++++
web.rb                   | 56 ++++++++++++++++-------
4 files changed, 143 insertions(+), 21 deletions(-)

Detailed changes

lib/reachability_repo.rb 🔗

@@ -21,7 +21,7 @@ class ReachabilityRepo
 	end
 
 	class NotTest
-		def filter
+		def filter(**)
 			EMPromise.resolve(yield)
 		end
 	end
@@ -32,8 +32,8 @@ class ReachabilityRepo
 			@key = key
 		end
 
-		def filter
-			@redis.incr(@key).then { nil }
+		def filter(if_yes: ->(_) {}, **)
+			@redis.incr(@key).then(&if_yes)
 		end
 	end
 

test/test_helper.rb 🔗

@@ -274,6 +274,13 @@ class FakeRedis
 	def lindex(key, index)
 		get(key).then { |v| v&.fetch(index) }
 	end
+
+	def incr(key)
+		get(key).then { |v|
+			n = v ? v + 1 : 0
+			set(key, n).then { n }
+		}
+	end
 end
 
 class FakeDB

test/test_web.rb 🔗

@@ -12,6 +12,8 @@ CustomerFwd::BANDWIDTH_VOICE = Minitest::Mock.new
 Web::BANDWIDTH_VOICE = Minitest::Mock.new
 LowBalance::AutoTopUp::CreditCardSale = Minitest::Mock.new
 
+ReachableRedis = Minitest::Mock.new
+
 class WebTest < Minitest::Test
 	include Rack::Test::Methods
 
@@ -28,6 +30,8 @@ class WebTest < Minitest::Test
 				"catapult_jid-+15551234562" => "customer_customerid_topup@component",
 				"jmp_customer_jid-customerid_limit" => "customer@example.com",
 				"catapult_jid-+15551234561" => "customer_customerid_limit@component",
+				"jmp_customer_jid-customerid_reach" => "customerid_reach@example.com",
+				"catapult_jid-+15551234563" => "customer_customerid_reach@component",
 				"jmp_customer_jid-customerid2" => "customer2@example.com",
 				"catapult_jid-+15551230000" => "customer_customerid2@component"
 			),
@@ -52,6 +56,11 @@ class WebTest < Minitest::Test
 					"plan_name" => "test_usd",
 					"expires_at" => Time.now + 100
 				}],
+				["customerid_reach"] => [{
+					"balance" => BigDecimal(10),
+					"plan_name" => "test_usd",
+					"expires_at" => Time.now + 100
+				}],
 				["customerid_limit"] => [{
 					"balance" => BigDecimal(10),
 					"plan_name" => "test_usd",
@@ -66,6 +75,8 @@ class WebTest < Minitest::Test
 					"catapult_fwd_timeout-customer_customerid_low@component" => "30",
 					"catapult_fwd-+15551234561" => "xmpp:customer@example.com",
 					"catapult_fwd_timeout-customer_customerid_limit@component" => "30",
+					"catapult_fwd-+15551234563" => "xmpp:customer@example.com",
+					"catapult_fwd_timeout-customer_customerid_reach@component" => "30",
 					"catapult_fwd-+15551230000" => "xmpp:customer2@example.com",
 					"catapult_fwd_timeout-customer_customerid2@component" => "30"
 				),
@@ -83,6 +94,10 @@ class WebTest < Minitest::Test
 							Blather::Stanza::Iq::IBR.new.tap do |ibr|
 								ibr.phone = "+15551234567"
 							end,
+						"customer_customerid_reach@component" =>
+							Blather::Stanza::Iq::IBR.new.tap do |ibr|
+								ibr.phone = "+15551234563"
+							end,
 						"customer_customerid_limit@component" =>
 							Blather::Stanza::Iq::IBR.new.tap do |ibr|
 								ibr.phone = "+15551234567"
@@ -96,6 +111,7 @@ class WebTest < Minitest::Test
 			db: FakeDB.new(
 				["test_usd", "+15557654321", :outbound] => [{ "rate" => 0.01 }],
 				["test_usd", "+15557654321", :inbound] => [{ "rate" => 0.01 }],
+				["test_usd", "+14445556666", :inbound] => [{ "rate" => 0.01 }],
 				["customerid_limit"] => FakeDB::MultiResult.new(
 					[{ "a" => 1000 }],
 					[{ "settled_amount" => 15 }]
@@ -111,6 +127,10 @@ class WebTest < Minitest::Test
 			)
 		)
 		Web.opts[:common_logger] = FakeLog.new
+		Web.opts[:reachability_repo] = ReachabilityRepo::Voice.new(
+			redis: ReachableRedis,
+			senders: ["+14445556666"]
+		)
 		Web.instance_variable_set(:@outbound_transfers, { "bcall" => "oocall" })
 		Web.app
 	end
@@ -351,6 +371,47 @@ class WebTest < Minitest::Test
 	end
 	em :test_inbound
 
+	def test_inbound_from_reachability
+		CustomerFwd::BANDWIDTH_VOICE.expect(
+			:create_call,
+			OpenStruct.new(data: OpenStruct.new(call_id: "ocall")),
+			["test_bw_account"],
+			body: Matching.new do |arg|
+				assert_equal(
+					"http://example.org/inbound/calls/acall?customer_id=customerid",
+					arg.answer_url
+				)
+			end
+		)
+
+		ReachableRedis.expect(
+			:exists,
+			EMPromise.resolve(0),
+			["jmp_customer_reachability_voice-customerid"]
+		)
+
+		post(
+			"/inbound/calls",
+			{
+				from: "+14445556666",
+				to: "+15551234567",
+				callId: "acall"
+			}.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<Ring answerCall=\"false\" duration=\"300\" />" \
+			"</Response>",
+			last_response.body
+		)
+		assert_mock CustomerFwd::BANDWIDTH_VOICE
+		assert_mock ReachableRedis
+	end
+	em :test_inbound_from_reachability
+
 	def test_inbound_no_bwmsgsv2
 		CustomerFwd::BANDWIDTH_VOICE.expect(
 			:create_call,
@@ -645,4 +706,38 @@ class WebTest < Minitest::Test
 		)
 	end
 	em :test_voicemail_no_customer
+
+	def test_inbound_from_reachability_during_reachability
+		ReachableRedis.expect(
+			:exists,
+			EMPromise.resolve(1),
+			["jmp_customer_reachability_voice-customerid_reach"]
+		)
+		ReachableRedis.expect(
+			:incr,
+			EMPromise.resolve(1),
+			["jmp_customer_reachability_voice-customerid_reach"]
+		)
+
+		post(
+			"/inbound/calls",
+			{
+				from: "+14445556666",
+				to: "+15551234563",
+				callId: "acall"
+			}.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<Hangup />" \
+			"</Response>",
+			last_response.body
+		)
+		assert_mock CustomerFwd::BANDWIDTH_VOICE
+		assert_mock ReachableRedis
+	end
+	em :test_inbound_from_reachability_during_reachability
 end

web.rb 🔗

@@ -16,6 +16,7 @@ require_relative "lib/rev_ai"
 require_relative "lib/roda_capture"
 require_relative "lib/roda_em_promise"
 require_relative "lib/rack_fiber"
+require_relative "lib/reachability_repo"
 
 class OGMDownload
 	def initialize(url)
@@ -107,6 +108,10 @@ class Web < Roda
 		opts[:customer_repo] || CustomerRepo.new(**kwargs)
 	end
 
+	def reachability_repo(**kwargs)
+		opts[:reachability_repo] || ReachabilityRepo::Voice.new(**kwargs)
+	end
+
 	def find_by_tel_with_fallback(sgx_repo:, **kwargs)
 		customer_repo(sgx_repo: sgx_repo).find_by_tel(params["to"]).catch { |e|
 			next EMPromise.reject(e) if e.is_a?(CustomerRepo::NotFound)
@@ -193,6 +198,24 @@ class Web < Roda
 		)
 	end
 
+	def call_inputs(customer, from, call_id)
+		EMPromise.all([
+			customer.customer_id, customer.fwd,
+			call_attempt_repo.find_inbound(customer, from, call_id: call_id)
+		])
+	end
+
+	def create_call(customer, from, call_id, application_id)
+		call_inputs(customer, from, call_id).then do |(customer_id, fwd, ca)|
+			ca.create_call(fwd, CONFIG[:creds][:account]) do |cc|
+				cc.from = from
+				cc.application_id = application_id
+				cc.answer_url = url inbound_calls_path(nil, customer_id)
+				cc.disconnect_url = url inbound_calls_path(:transfer_complete)
+			end
+		end
+	end
+
 	route do |r|
 		r.on "inbound" do
 			r.on "calls" do
@@ -330,24 +353,21 @@ class Web < Roda
 					customer_repo(
 						sgx_repo: Bwmsgsv2Repo.new
 					).find_by_tel(params["to"]).then { |customer|
-						EMPromise.all([
-							customer.customer_id, customer.fwd,
-							call_attempt_repo.find_inbound(
-								customer, params["from"], call_id: params["callId"]
-							)
-						])
-					}.then { |(customer_id, fwd, ca)|
-						call = ca.create_call(fwd, CONFIG[:creds][:account]) { |cc|
-							cc.from = params["from"]
-							cc.application_id = params["applicationId"]
-							cc.answer_url = url inbound_calls_path(nil, customer_id)
-							cc.disconnect_url = url inbound_calls_path(:transfer_complete)
-						}
-
-						next EMPromise.reject(:voicemail) unless call
-
-						outbound_transfers[params["callId"]] = call
-						render :ring, locals: { duration: 300 }
+						reachability_repo.find(customer, params["from"]).then do |reach|
+							reach.filter(if_yes: ->(_) { render :hangup }) do
+								create_call(
+									customer,
+									params["from"],
+									params["callId"],
+									params["applicationId"]
+								).then { |call|
+									next EMPromise.reject(:voicemail) unless call
+
+									outbound_transfers[params["callId"]] = call
+									render :ring, locals: { duration: 300 }
+								}
+							end
+						end
 					}.catch_only(CustomerFwd::InfiniteTimeout) { |e|
 						render :forward, locals: { fwd: e.fwd, from: params["from"] }
 					}.catch { |e|