Also supports limits on inbound calls

Stephen Paul Weber created

Change summary

lib/call_attempt.rb            |  48 ++++++-
lib/call_attempt_repo.rb       |   6 
lib/customer_fwd.rb            |   1 
test/test_helper.rb            |  14 ++
test/test_web.rb               | 203 ++++++++++++++++++++++++++++++++++-
views/inbound/at_limit.slim    |   5 
views/inbound/connect.slim     |   1 
views/inbound/no_balance.slim  |   3 
views/inbound/unsupported.slim |   3 
views/outbound/connect.slim    |   0 
views/ring.slim                |   2 
web.rb                         |  27 +++
12 files changed, 287 insertions(+), 26 deletions(-)

Detailed changes

lib/call_attempt.rb 🔗

@@ -8,54 +8,80 @@ class CallAttempt
 		"cad_beta_unlimited-v20210223" => 1.1
 	}.freeze
 
-	def self.for(customer, other_tel, rate, usage, digits)
+	def self.for(customer, other_tel, rate, usage, direction:, **kwargs)
 		included_credit = [customer.minute_limit.to_d - usage, 0].max
 		if !rate || rate >= EXPENSIVE_ROUTE.fetch(customer.plan_name, 0.1)
-			Unsupported.new
+			Unsupported.new(direction: direction)
 		elsif included_credit + customer.balance < rate * 10
-			NoBalance.new(balance: customer.balance)
+			NoBalance.new(balance: customer.balance, direction: direction)
 		else
-			for_ask_or_go(customer, other_tel, rate, usage, digits)
+			for_ask_or_go(
+				customer, other_tel, rate, usage, direction: direction, **kwargs
+			)
 		end
 	end
 
-	def self.for_ask_or_go(customer, other_tel, rate, usage, digits)
+	def self.for_ask_or_go(customer, otel, rate, usage, digits: nil, **kwargs)
 		can_use = customer.minute_limit.to_d + customer.monthly_overage_limit
 		if digits != "1" && can_use - usage < rate * 10
-			AtLimit.new
+			AtLimit.new(**kwargs.slice(:direction, :call_id))
 		else
-			new(from: customer.registered?.phone, to: other_tel)
+			new(from: customer.registered?.phone, to: otel, **kwargs)
 		end
 	end
 
 	value_semantics do
 		from(/\A\+\d+\Z/)
 		to(/\A\+\d+\Z/)
+		call_id String
+		direction Either(:inbound, :outbound)
 	end
 
 	def to_render
-		[:forward, { locals: to_h }]
+		["#{direction}/connect", { locals: to_h }]
+	end
+
+	def create_call(fwd, *args, &block)
+		fwd.create_call(*args, &block)
 	end
 
 	class Unsupported
+		value_semantics do
+			direction Either(:inbound, :outbound)
+		end
+
 		def to_render
-			["outbound/unsupported"]
+			["#{direction}/unsupported"]
 		end
+
+		def create_call(*); end
 	end
 
 	class NoBalance
 		value_semantics do
 			balance Numeric
+			direction Either(:inbound, :outbound)
 		end
 
 		def to_render
-			["outbound/no_balance", { locals: to_h }]
+			["#{direction}/no_balance", { locals: to_h }]
 		end
+
+		def create_call(*); end
 	end
 
 	class AtLimit
+		value_semantics do
+			call_id String
+			direction Either(:inbound, :outbound)
+		end
+
 		def to_render
-			["outbound/at_limit"]
+			["#{direction}/at_limit", { locals: to_h }]
+		end
+
+		def create_call(fwd, *args, &block)
+			fwd.create_call(*args, &block)
 		end
 	end
 end

lib/call_attempt_repo.rb 🔗

@@ -9,12 +9,14 @@ class CallAttemptRepo
 		db Anything(), default: LazyObject.new { DB }
 	end
 
-	def find(customer, other_tel, digits=nil, direction=:outbound)
+	def find(customer, other_tel, direction: :outbound, **kwargs)
 		EMPromise.all([
 			find_rate(customer.plan_name, other_tel, direction),
 			find_usage(customer.customer_id)
 		]).then do |(rate, usage)|
-			CallAttempt.for(customer, other_tel, rate, usage, digits)
+			CallAttempt.for(
+				customer, other_tel, rate, usage, direction: direction, **kwargs
+			)
 		end
 	end
 

lib/customer_fwd.rb 🔗

@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require "bandwidth"
 require "value_semantics/monkey_patched"
 require "uri"
 

test/test_helper.rb 🔗

@@ -205,6 +205,20 @@ class FakeDB
 	end
 end
 
+class FakeLog
+	def initialize
+		@logs = []
+	end
+
+	def respond_to_missing?(*)
+		true
+	end
+
+	def method_missing(*args)
+		@logs << args
+	end
+end
+
 class FakeIBRRepo
 	def initialize(registrations={})
 		@registrations = registrations

test/test_web.rb 🔗

@@ -5,6 +5,8 @@ require "test_helper"
 require_relative "../web"
 
 Customer::BLATHER = Minitest::Mock.new
+CustomerFwd::BANDWIDTH_VOICE = Minitest::Mock.new
+Web::BANDWIDTH_VOICE = Minitest::Mock.new
 
 class WebTest < Minitest::Test
 	include Rack::Test::Methods
@@ -15,7 +17,9 @@ class WebTest < Minitest::Test
 				"jmp_customer_jid-customerid" => "customer@example.com",
 				"catapult_jid-+15551234567" => "customer_customerid@component",
 				"jmp_customer_jid-customerid_low" => "customer@example.com",
-				"jmp_customer_jid-customerid_limit" => "customer@example.com"
+				"catapult_jid-+15551234560" => "customer_customerid_low@component",
+				"jmp_customer_jid-customerid_limit" => "customer@example.com",
+				"catapult_jid-+15551234561" => "customer_customerid_limit@component"
 			),
 			db: FakeDB.new(
 				["customerid"] => [{
@@ -35,7 +39,14 @@ class WebTest < Minitest::Test
 				}]
 			),
 			sgx_repo: Bwmsgsv2Repo.new(
-				redis: FakeRedis.new,
+				redis: FakeRedis.new(
+					"catapult_fwd-+15551234567" => "xmpp:customer@example.com",
+					"catapult_fwd_timeout-customer_customerid@component" => "30",
+					"catapult_fwd-+15551234560" => "xmpp:customer@example.com",
+					"catapult_fwd_timeout-customer_customerid_low@component" => "30",
+					"catapult_fwd-+15551234561" => "xmpp:customer@example.com",
+					"catapult_fwd_timeout-customer_customerid_limit@component" => "30"
+				),
 				ibr_repo: FakeIBRRepo.new(
 					"sgx" => {
 						"customer_customerid@component" => IBR.new.tap do |ibr|
@@ -51,17 +62,24 @@ class WebTest < Minitest::Test
 		Web.opts[:call_attempt_repo] = CallAttemptRepo.new(
 			db: FakeDB.new(
 				["test_usd", "+15557654321", :outbound] => [{ "rate" => 0.01 }],
+				["test_usd", "+15557654321", :inbound] => [{ "rate" => 0.01 }],
 				["customerid_limit"] => [{ "a" => -1000 }],
 				["customerid_low"] => [{ "a" => -1000 }]
 			)
 		)
+		Web.opts[:common_logger] = FakeLog.new
+		Web.instance_variable_set(:@outbound_transfers, { "bcall" => "oocall" })
 		Web.app
 	end
 
 	def test_outbound_forwards
 		post(
 			"/outbound/calls",
-			{ from: "customerid", to: "+15557654321" }.to_json,
+			{
+				from: "customerid",
+				to: "+15557654321",
+				callId: "acall"
+			}.to_json,
 			{ "CONTENT_TYPE" => "application/json" }
 		)
 
@@ -78,7 +96,11 @@ class WebTest < Minitest::Test
 	def test_outbound_low_balance
 		post(
 			"/outbound/calls",
-			{ from: "customerid_low", to: "+15557654321" }.to_json,
+			{
+				from: "customerid_low",
+				to: "+15557654321",
+				callId: "acall"
+			}.to_json,
 			{ "CONTENT_TYPE" => "application/json" }
 		)
 
@@ -95,7 +117,11 @@ class WebTest < Minitest::Test
 	def test_outbound_unsupported
 		post(
 			"/outbound/calls",
-			{ from: "customerid", to: "+95557654321" }.to_json,
+			{
+				from: "customerid_limit",
+				to: "+95557654321",
+				callId: "acall"
+			}.to_json,
 			{ "CONTENT_TYPE" => "application/json" }
 		)
 
@@ -112,7 +138,11 @@ class WebTest < Minitest::Test
 	def test_outbound_atlimit
 		post(
 			"/outbound/calls",
-			{ from: "customerid_limit", to: "+15557654321" }.to_json,
+			{
+				from: "customerid_limit",
+				to: "+15557654321",
+				callId: "acall"
+			}.to_json,
 			{ "CONTENT_TYPE" => "application/json" }
 		)
 
@@ -132,7 +162,12 @@ class WebTest < Minitest::Test
 	def test_outbound_atlimit_digits
 		post(
 			"/outbound/calls",
-			{ from: "customerid_limit", to: "+15557654321", digits: "1" }.to_json,
+			{
+				from: "customerid_limit",
+				to: "+15557654321",
+				callId: "acall",
+				digits: "1"
+			}.to_json,
 			{ "CONTENT_TYPE" => "application/json" }
 		)
 
@@ -146,6 +181,160 @@ class WebTest < Minitest::Test
 	end
 	em :test_outbound_atlimit_digits
 
+	def test_inbound
+		CustomerFwd::BANDWIDTH_VOICE.expect(
+			:create_call,
+			OpenStruct.new(data: OpenStruct.new(call_id: "ocall")),
+			[
+				"test_bw_account",
+				Matching.new { |arg| assert_equal [:body], arg.keys }
+			]
+		)
+
+		post(
+			"/inbound/calls",
+			{
+				from: "+15557654321",
+				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
+	end
+	em :test_inbound
+
+	def test_inbound_low
+		post(
+			"/inbound/calls",
+			{
+				from: "+15557654321",
+				to: "+15551234560",
+				callId: "acall"
+			}.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<Redirect redirectUrl=\"/inbound/calls/acall/voicemail\" />" \
+			"</Response>",
+			last_response.body
+		)
+		assert_mock CustomerFwd::BANDWIDTH_VOICE
+	end
+	em :test_inbound_low
+
+	def test_inbound_leg2
+		post(
+			"/inbound/calls/acall",
+			{
+				from: "+15557654321",
+				to: "+15551234567",
+				callId: "ocall"
+			}.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<Tag>connected</Tag><Bridge>acall</Bridge>" \
+			"</Response>",
+			last_response.body
+		)
+	end
+	em :test_inbound_leg2
+
+	def test_inbound_limit_leg2
+		post(
+			"/inbound/calls/acall",
+			{
+				from: "+15557654321",
+				to: "+15551234561",
+				callId: "ocall"
+			}.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<Gather gatherUrl=\"\/inbound/calls/acall\" maxDigits=\"1\" " \
+			"repeatCount=\"3\"><SpeakSentence>This call will take you over " \
+			"your configured monthly overage limit.</SpeakSentence><SpeakSentence>" \
+			"Change your limit in your account settings or press 1 to accept the " \
+			"charges. You can hang up to send the caller to voicemail." \
+			"</SpeakSentence></Gather></Response>",
+			last_response.body
+		)
+	end
+	em :test_inbound_limit_leg2
+
+	def test_inbound_limit_digits_leg2
+		post(
+			"/inbound/calls/acall",
+			{
+				from: "+15557654321",
+				to: "+15551234561",
+				callId: "ocall",
+				digits: "1"
+			}.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<Tag>connected</Tag><Bridge>acall</Bridge>" \
+			"</Response>",
+			last_response.body
+		)
+	end
+	em :test_inbound_limit_digits_leg2
+
+	def test_inbound_limit_hangup
+		Web::BANDWIDTH_VOICE.expect(
+			:modify_call,
+			nil,
+			[
+				"test_bw_account",
+				"bcall",
+				Matching.new do |arg|
+					assert_equal [:body], arg.keys
+					assert_equal(
+						"http://example.org/inbound/calls/oocall/voicemail",
+						arg[:body].redirect_url
+					)
+				end
+			]
+		)
+
+		post(
+			"/inbound/calls/bcall/transfer_complete",
+			{
+				from: "+15557654321",
+				to: "+15551234561",
+				callId: "oocall",
+				cause: "hangup"
+			}.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_mock Web::BANDWIDTH_VOICE
+	end
+	em :test_inbound_limit_hangup
+
 	def test_voicemail
 		Customer::BLATHER.expect(
 			:<<,

views/inbound/at_limit.slim 🔗

@@ -0,0 +1,5 @@
+doctype xml
+Response
+	Gather gatherUrl="/inbound/calls/#{call_id}" maxDigits="1" repeatCount="3"
+		SpeakSentence This call will take you over your configured monthly overage limit.
+		SpeakSentence Change your limit in your account settings or press 1 to accept the charges. You can hang up to send the caller to voicemail.

views/ring.slim 🔗

@@ -1,3 +1,3 @@
 doctype xml
 Response
-	Ring duration=duration answerCall="false"
+	Ring duration=duration answerCall="false" /

web.rb 🔗

@@ -172,7 +172,7 @@ class Web < Roda
 				r.on :call_id do |call_id|
 					r.post "transfer_complete" do
 						outbound_leg = outbound_transfers.delete(call_id)
-						if params["cause"] == "hangup"
+						if params["cause"] == "hangup" && params["tag"] == "connected"
 							log.info "Normal hangup, now end #{call_id}", loggable_params
 							modify_call(call_id) { |call| call.state = "completed" }
 						elsif !outbound_leg
@@ -244,15 +244,31 @@ class Web < Roda
 					end
 
 					r.post do
-						render :bridge, locals: { call_id: call_id }
+						customer_repo.find_by_tel(params["to"]).then do |customer|
+							call_attempt_repo.find(
+								customer,
+								params["from"],
+								call_id: call_id,
+								digits: params["digits"],
+								direction: :inbound
+							).then { |ca| render(*ca.to_render) }
+						end
 					end
 				end
 
 				r.post do
 					customer_repo(
 						sgx_repo: Bwmsgsv2Repo.new
-					).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
-						call = fwd.create_call(CONFIG[:creds][:account]) { |cc|
+					).find_by_tel(params["to"]).then { |customer|
+						EMPromise.all([
+							customer.fwd,
+							call_attempt_repo.find(
+								customer, params["from"],
+								call_id: params["callId"], direction: :inbound
+							)
+						])
+					}.then do |(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)
@@ -288,7 +304,8 @@ class Web < Roda
 						call_attempt_repo.find(
 							c,
 							params["to"],
-							params["digits"]
+							call_id: params["callId"],
+							digits: params["digits"]
 						).then { |ca| render(*ca.to_render) }
 					end
 				end