~singpolyma/sgx-jmp

3e7035106f3121e8fdf22d0f20ecc835bc620815 — Stephen Paul Weber 1 year, 7 months ago 11ac679
Also supports limits on inbound calls
12 files changed, 287 insertions(+), 26 deletions(-)

M lib/call_attempt.rb
M lib/call_attempt_repo.rb
M lib/customer_fwd.rb
M test/test_helper.rb
M test/test_web.rb
A views/inbound/at_limit.slim
R views/{bridge.slim => inbound/connect.slim}
A views/inbound/no_balance.slim
A views/inbound/unsupported.slim
R views/{forward.slim => outbound/connect.slim}
M views/ring.slim
M web.rb
M lib/call_attempt.rb => lib/call_attempt.rb +37 -11
@@ 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

M lib/call_attempt_repo.rb => lib/call_attempt_repo.rb +4 -2
@@ 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


M lib/customer_fwd.rb => lib/customer_fwd.rb +1 -0
@@ 1,5 1,6 @@
# frozen_string_literal: true

require "bandwidth"
require "value_semantics/monkey_patched"
require "uri"


M test/test_helper.rb => test/test_helper.rb +14 -0
@@ 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

M test/test_web.rb => test/test_web.rb +196 -7
@@ 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(
			:<<,

A views/inbound/at_limit.slim => views/inbound/at_limit.slim +5 -0
@@ 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.

R views/bridge.slim => views/inbound/connect.slim +1 -0
@@ 1,3 1,4 @@
doctype xml
Response
	Tag connected
	Bridge= call_id

A views/inbound/no_balance.slim => views/inbound/no_balance.slim +3 -0
@@ 0,0 1,3 @@
doctype xml
Response
	Hangup /

A views/inbound/unsupported.slim => views/inbound/unsupported.slim +3 -0
@@ 0,0 1,3 @@
doctype xml
Response
	Hangup /

R views/forward.slim => views/outbound/connect.slim +0 -0
M views/ring.slim => views/ring.slim +1 -1
@@ 1,3 1,3 @@
doctype xml
Response
	Ring duration=duration answerCall="false"
	Ring duration=duration answerCall="false" /

M web.rb => web.rb +22 -5
@@ 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