Move CustomerFwd behind Customer

Stephen Paul Weber created

All the previously-lazy BackendSgx data is now either all loaded or all not
loaded by swapping the sgx_repo used by your CustomerRepo instance.  When not
loaded the fields are filled with bottom values that explode when used.  When
loaded the values are present in RAM and not promises at all.  Most code paths
do not need any of the data, a few need most of it, so this seems like a good
trade-off.  Most code using this object will simply never touch those fields or
care about how they are loaded, etc.

Of course, most of this data isn't even SGX related and should move out of here,
but that would take a data model refactor/migration on the catapult_* schema.

Change summary

lib/backend_sgx.rb              |  71 ++++-----------
lib/bwmsgsv2_repo.rb            |  63 ++++++++++++++
lib/command.rb                  |  12 +-
lib/command_list.rb             |  24 ++--
lib/customer.rb                 |   9 -
lib/customer_fwd.rb             |  72 ++++++++++++++++
lib/customer_info.rb            |  11 --
lib/customer_info_form.rb       |   3 
lib/customer_repo.rb            |  24 ++++-
lib/not_loaded.rb               |  17 +++
lib/registration.rb             |  12 +-
lib/trivial_backend_sgx_repo.rb |  28 ++++++
sgx_jmp.rb                      |  31 ++++--
test/test_backend_sgx.rb        |  20 ++--
test/test_command_list.rb       |  26 +----
test/test_customer_info.rb      |   8 
test/test_customer_repo.rb      |  10 +-
test/test_helper.rb             |   8 +
test/test_registration.rb       |   8 
web.rb                          | 155 ++++++----------------------------
20 files changed, 337 insertions(+), 275 deletions(-)

Detailed changes

lib/backend_sgx.rb 🔗

@@ -1,71 +1,40 @@
 # frozen_string_literal: true
 
-class BackendSgx
-	VOICEMAIL_TRANSCRIPTION_DISABLED = 0
+require "value_semantics/monkey_patched"
+
+require_relative "customer_fwd"
+require_relative "ibr"
+require_relative "not_loaded"
 
-	def initialize(customer_id, jid=CONFIG[:sgx], creds=CONFIG[:creds])
-		@customer_id = customer_id
-		@jid = jid
-		@creds = creds
+class BackendSgx
+	value_semantics do
+		jid Blather::JID
+		creds HashOf(Symbol => String)
+		from_jid Blather::JID
+		ogm_url Either(String, nil, NotLoaded)
+		fwd Either(CustomerFwd, nil, NotLoaded)
+		transcription_enabled Either(Bool(), NotLoaded)
+		registered? Either(IBR, FalseClass, NotLoaded)
 	end
 
 	def register!(tel)
-		ibr = mkibr(:set)
-		ibr.nick = @creds[:account]
-		ibr.username = @creds[:username]
-		ibr.password = @creds[:password]
+		ibr = IBR.new(:set, @jid)
+		ibr.from = from_jid
+		ibr.nick = creds[:account]
+		ibr.username = creds[:username]
+		ibr.password = creds[:password]
 		ibr.phone = tel
 		IQ_MANAGER.write(ibr)
 	end
 
-	def registered?
-		IQ_MANAGER.write(mkibr(:get)).catch { nil }.then do |result|
-			if result&.respond_to?(:registered?) && result&.registered?
-				result
-			else
-				false
-			end
-		end
-	end
-
 	def stanza(s)
 		s.dup.tap do |stanza|
-			stanza.to = stanza.to.with(domain: @jid)
+			stanza.to = stanza.to.with(domain: jid.domain)
 			stanza.from = from_jid.with(resource: stanza.from.resource)
 		end
 	end
 
-	def ogm_url
-		REDIS.get("catapult_ogm_url-#{from_jid}")
-	end
-
-	def catapult_flag(flagbit)
-		REDIS.getbit(
-			"catapult_setting_flags-#{from_jid}",
-			flagbit
-		).then { |x| x == 1 }
-	end
-
-	def fwd_timeout
-		REDIS.get("catapult_fwd_timeout-#{from_jid}")
-	end
-
 	def set_fwd_timeout(timeout)
 		REDIS.set("catapult_fwd_timeout-#{from_jid}", timeout)
 	end
-
-protected
-
-	def from_jid
-		Blather::JID.new(
-			"customer_#{@customer_id}",
-			CONFIG[:component][:jid]
-		)
-	end
-
-	def mkibr(type)
-		ibr = IBR.new(type, @jid)
-		ibr.from = from_jid
-		ibr
-	end
 end

lib/bwmsgsv2_repo.rb 🔗

@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "lazy_object"
+
+require_relative "customer_fwd"
+require_relative "ibr"
+require_relative "trivial_backend_sgx_repo"
+
+class Bwmsgsv2Repo
+	VOICEMAIL_TRANSCRIPTION_DISABLED = 0
+
+	def initialize(jid: CONFIG[:sgx], redis: LazyObject.new { REDIS }, **kwargs)
+		@jid = jid
+		@redis = redis
+		@trivial_repo = TrivialBackendSgxRepo.new(jid: jid, **kwargs)
+	end
+
+	def get(customer_id)
+		sgx = @trivial_repo.get(customer_id)
+		fetch_raw(sgx.from_jid).then do |(((ogm_url, fwd_time, fwd), trans_d), reg)|
+			sgx.with({
+				ogm_url: ogm_url,
+				fwd: CustomerFwd.for(fwd, fwd_time),
+				transcription_enabled: !trans_d,
+				registered?: reg
+			}.compact)
+		end
+	end
+
+protected
+
+	def fetch_raw(from_jid)
+		registration(from_jid).then do |r|
+			EMPromise.all([from_redis(from_jid, r ? r.phone : nil), r])
+		end
+	end
+
+	def registration(from_jid)
+		ibr = IBR.new(:get, @jid)
+		ibr.from = from_jid
+
+		IQ_MANAGER.write(ibr).catch { nil }.then do |result|
+			if result&.respond_to?(:registered?) && result&.registered?
+				result
+			else
+				false
+			end
+		end
+	end
+
+	def from_redis(from_jid, tel)
+		EMPromise.all([
+			@redis.mget(*[
+				"catapult_ogm_url-#{from_jid}",
+				"catapult_fwd_timeout-#{from_jid}",
+				("catapult_fwd-#{tel}" if tel)
+			].compact),
+			@redis.getbit(
+				"catapult_setting_flags-#{from_jid}", VOICEMAIL_TRANSCRIPTION_DISABLED
+			).then { |x| x == 1 }
+		])
+	end
+end

lib/command.rb 🔗

@@ -53,7 +53,7 @@ class Command
 			EMPromise.resolve(nil).then {
 				Thread.current[:execution] = self
 				sentry_hub
-				catch_after(yield self)
+				catch_after(EMPromise.resolve(yield self))
 			}.catch(&method(:panic))
 		end
 
@@ -141,21 +141,21 @@ class Command
 	def initialize(
 		node,
 		name,
+		customer_repo: CustomerRepo.new,
 		list_for: ->(tel:, **) { !!tel },
-		format_error: ->(e) { e.respond_to?(:message) ? e.message : e.to_s },
-		&blk
+		format_error: ->(e) { e.respond_to?(:message) ? e.message : e.to_s }
 	)
 		@node = node
 		@name = name
+		@customer_repo = customer_repo
 		@list_for = list_for
 		@format_error = format_error
-		@blk = blk
+		@blk = ->(exe) { yield exe }
 	end
 
 	def register(blather, guards: [:execute?, node: @node, sessionid: nil])
 		blather.command(*guards) do |iq|
-			customer_repo = CustomerRepo.new
-			Execution.new(customer_repo, blather, @format_error, iq).execute(&@blk)
+			Execution.new(@customer_repo, blather, @format_error, iq).execute(&@blk)
 		end
 		self
 	end

lib/command_list.rb 🔗

@@ -9,22 +9,22 @@ class CommandList
 	end
 
 	def self.for(customer)
-		EMPromise.resolve(customer&.registered?).catch { nil }.then do |reg|
-			args_for(customer, reg).then do |kwargs|
-				new(@commands.select { |c| c.list_for?(**kwargs) })
-			end
+		args_for(customer).then do |kwargs|
+			new(@commands.select { |c| c.list_for?(**kwargs) })
 		end
 	end
 
-	def self.args_for(customer, reg)
-		args = { customer: customer, tel: reg ? reg.phone : nil }
-		return EMPromise.resolve(args) unless args[:tel]
+	def self.args_for(customer)
+		args = {
+			customer: customer,
+			tel: customer&.registered? ? customer&.registered?&.phone : nil,
+			fwd: customer&.fwd,
+			payment_methods: []
+		}
+		return EMPromise.resolve(args) unless customer&.plan_name
 
-		EMPromise.all([
-			REDIS.get("catapult_fwd-#{args[:tel]}"),
-			customer.plan_name ? customer.payment_methods : []
-		]).then do |(fwd, payment_methods)|
-			args.merge(fwd: fwd, payment_methods: payment_methods)
+		customer.payment_methods.then do |payment_methods|
+			args.merge(payment_methods: payment_methods)
 		end
 	end
 

lib/customer.rb 🔗

@@ -14,6 +14,7 @@ require_relative "./payment_methods"
 require_relative "./plan"
 require_relative "./proxied_jid"
 require_relative "./sip_account"
+require_relative "./trivial_backend_sgx_repo"
 
 class Customer
 	extend Forwardable
@@ -22,7 +23,7 @@ class Customer
 	def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan,
 	               :currency, :merchant_account, :plan_name, :auto_top_up_amount
 	def_delegators :@sgx, :register!, :registered?,
-	               :fwd_timeout, :set_fwd_timeout, :catapult_flag
+	               :set_fwd_timeout, :fwd, :transcription_enabled
 	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage
 
 	def initialize(
@@ -30,7 +31,7 @@ class Customer
 		jid,
 		plan: CustomerPlan.new(customer_id),
 		balance: BigDecimal(0),
-		sgx: BackendSgx.new(customer_id)
+		sgx: TrivialBackendSgxRepo.new.get(customer_id)
 	)
 		@plan = plan
 		@usage = CustomerUsage.new(customer_id)
@@ -83,9 +84,7 @@ class Customer
 	end
 
 	def ogm(from_tel=nil)
-		@sgx.ogm_url.then do |url|
-			CustomerOGM.for(url, -> { fetch_vcard_temp(from_tel) })
-		end
+		CustomerOGM.for(@sgx.ogm_url, -> { fetch_vcard_temp(from_tel) })
 	end
 
 	def sip_account

lib/customer_fwd.rb 🔗

@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require "uri"
+
+class CustomerFwd
+	def self.for(uri, timeout)
+		timeout = Timeout.new(timeout)
+		return if !uri || timeout.zero?
+		URIS.fetch(uri.split(":", 2).first.to_sym) {
+			raise "Unknown forward URI: #{uri}"
+		}.new(uri, timeout)
+	end
+
+	class Timeout
+		def initialize(s)
+			@timeout = s.nil? || s.to_i.negative? ? 300 : s.to_i
+		end
+
+		def zero?
+			@timeout.zero?
+		end
+
+		def to_i
+			@timeout
+		end
+	end
+
+	class Tel < CustomerFwd
+		attr_reader :timeout
+
+		def initialize(uri, timeout)
+			@tel = uri.sub(/^tel:/, "")
+			@timeout = timeout
+		end
+
+		def to
+			@tel
+		end
+	end
+
+	class SIP < CustomerFwd
+		attr_reader :timeout
+
+		def initialize(uri, timeout)
+			@uri = uri
+			@timeout = timeout
+		end
+
+		def to
+			@uri
+		end
+	end
+
+	class XMPP < CustomerFwd
+		attr_reader :timeout
+
+		def initialize(uri, timeout)
+			@jid = uri.sub(/^xmpp:/, "")
+			@timeout = timeout
+		end
+
+		def to
+			"sip:#{ERB::Util.url_encode(@jid)}@sip.cheogram.com"
+		end
+	end
+
+	URIS = {
+		tel: Tel,
+		sip: SIP,
+		xmpp: XMPP
+	}.freeze
+end

lib/customer_info.rb 🔗

@@ -15,24 +15,17 @@ class CustomerInfo
 	end
 
 	def self.for(customer, plan, expires_at)
-		fetch_inputs(customer, plan).then do |(auto_top_up_amount, registration)|
+		plan.auto_top_up_amount.then do |auto_top_up_amount|
 			new(
 				plan: plan,
 				auto_top_up_amount: auto_top_up_amount,
-				tel: registration ? registration.phone : nil,
+				tel: customer.registered? ? customer.regsitered?.phone : nil,
 				balance: customer.balance,
 				expires_at: expires_at
 			)
 		end
 	end
 
-	def self.fetch_inputs(customer, plan)
-		EMPromise.all([
-			plan.auto_top_up_amount,
-			customer.registered?
-		])
-	end
-
 	def account_status
 		if plan.plan_name.nil?
 			"Transitional"

lib/customer_info_form.rb 🔗

@@ -1,11 +1,12 @@
 # frozen_string_literal: true
 
+require_relative "bwmsgsv2_repo"
 require_relative "customer_repo"
 require_relative "proxied_jid"
 require_relative "legacy_customer"
 
 class CustomerInfoForm
-	def initialize(customer_repo=CustomerRepo.new)
+	def initialize(customer_repo=CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new))
 		@customer_repo = customer_repo
 	end
 

lib/customer_repo.rb 🔗

@@ -1,14 +1,22 @@
 # frozen_string_literal: true
 
+require "lazy_object"
+
 require_relative "customer"
 require_relative "legacy_customer"
 require_relative "polyfill"
 
 class CustomerRepo
-	def initialize(redis: REDIS, db: DB, braintree: BRAINTREE)
+	def initialize(
+		redis: LazyObject.new { REDIS },
+		db: LazyObject.new { DB },
+		braintree: LazyObject.new { BRAINTREE },
+		sgx_repo: TrivialBackendSgxRepo.new
+	)
 		@redis = redis
 		@db = db
 		@braintree = braintree
+		@sgx_repo = sgx_repo
 	end
 
 	def find(customer_id)
@@ -19,7 +27,7 @@ class CustomerRepo
 	end
 
 	def find_by_jid(jid)
-		if jid.to_s =~ /\Acustomer_(.+)@jmp.chat\Z/
+		if jid.to_s =~ /\Acustomer_(.+)@#{CONFIG[:component][:jid]}\Z/
 			find($1)
 		else
 			@redis.get("jmp_customer_id-#{jid}").then { |customer_id|
@@ -46,13 +54,19 @@ class CustomerRepo
 				"jmp_customer_id-#{jid}", cid, "jmp_customer_jid-#{cid}", jid
 			).then do |redis_result|
 				raise "Saving new customer to redis failed" unless redis_result == 1
-				Customer.new(cid, Blather::JID.new(jid))
+				Customer.new(cid, Blather::JID.new(jid), sgx: new_sgx(cid))
 			end
 		end
 	end
 
 protected
 
+	def new_sgx(customer_id)
+		TrivialBackendSgxRepo.new.get(customer_id).with(
+			registered?: false
+		)
+	end
+
 	def find_legacy_customer(jid)
 		@redis.lindex("catapult_cred-#{jid}", 3).then do |tel|
 			raise "No customer" unless tel
@@ -76,9 +90,9 @@ protected
 			FROM customer_plans LEFT JOIN balances USING (customer_id)
 			WHERE customer_id=$1 LIMIT 1
 		SQL
-		result.then do |rows|
+		EMPromise.all([@sgx_repo.get(customer_id), result]).then do |(sgx, rows)|
 			data = hydrate_plan(customer_id, rows.first&.transform_keys(&:to_sym) || {})
-			Customer.new(customer_id, Blather::JID.new(jid), **data)
+			Customer.new(customer_id, Blather::JID.new(jid), sgx: sgx, **data)
 		end
 	end
 end

lib/not_loaded.rb 🔗

@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class NotLoaded
+	class NotLoadedError < StandardError; end
+
+	def initialize(name)
+		@name = name
+	end
+
+	def respond_to_missing?(*)
+		true
+	end
+
+	def method_missing(*) # rubocop:disable Style/MethodMissing
+		raise NotLoadedError, "#{@name} not loaded"
+	end
+end

lib/registration.rb 🔗

@@ -13,13 +13,11 @@ require_relative "./tel_selections"
 
 class Registration
 	def self.for(customer, tel_selections)
-		customer.registered?.then do |registered|
-			if registered
-				Registered.new(registered.phone)
-			else
-				tel_selections[customer.jid].then(&:choose_tel).then do |tel|
-					Activation.for(customer, tel)
-				end
+		if (reg = customer.registered?)
+			Registered.new(reg.phone)
+		else
+			tel_selections[customer.jid].then(&:choose_tel).then do |tel|
+				Activation.for(customer, tel)
 			end
 		end
 	end

lib/trivial_backend_sgx_repo.rb 🔗

@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require_relative "backend_sgx"
+require_relative "not_loaded"
+
+class TrivialBackendSgxRepo
+	def initialize(
+		jid: CONFIG[:sgx],
+		creds: CONFIG[:creds],
+		component_jid: CONFIG[:component][:jid]
+	)
+		@jid = Blather::JID.new(jid)
+		@creds = creds
+		@component_jid = component_jid
+	end
+
+	def get(customer_id)
+		BackendSgx.new(
+			jid: @jid,
+			creds: @creds,
+			from_jid: Blather::JID.new("customer_#{customer_id}", @component_jid),
+			ogm_url: NotLoaded.new(:ogm_url),
+			fwd: NotLoaded.new(:fwd_timeout),
+			transcription_enabled: NotLoaded.new(:transcription_enabled),
+			registered?: NotLoaded.new(:registered?)
+		)
+	end
+end

sgx_jmp.rb 🔗

@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 require "pg/em/connection_pool"
+require "bandwidth"
 require "bigdecimal"
 require "blather/client/dsl" # Require this first to not auto-include
 require "blather/client"
@@ -68,6 +69,7 @@ require_relative "lib/polyfill"
 require_relative "lib/alt_top_up_form"
 require_relative "lib/add_bitcoin_address"
 require_relative "lib/backend_sgx"
+require_relative "lib/bwmsgsv2_repo"
 require_relative "lib/bandwidth_tn_order"
 require_relative "lib/btc_sell_prices"
 require_relative "lib/buy_account_credit_form"
@@ -100,6 +102,10 @@ BandwidthIris::Client.global_options = {
 	username: CONFIG[:creds][:username],
 	password: CONFIG[:creds][:password]
 }
+BANDWIDTH_VOICE = Bandwidth::Client.new(
+	voice_basic_auth_user_name: CONFIG[:creds][:username],
+	voice_basic_auth_password: CONFIG[:creds][:password]
+).voice_client.client
 
 def new_sentry_hub(stanza, name: nil)
 	hub = Sentry.get_current_hub&.new_from_top
@@ -200,7 +206,7 @@ when_ready do
 		self << ping
 	end
 
-	Web.run(LOG.child, CustomerRepo.new, *WEB_LISTEN)
+	Web.run(LOG.child, *WEB_LISTEN)
 end
 
 # workqueue_count MUST be 0 or else Blather uses threads!
@@ -258,9 +264,7 @@ message(
 		&.find { |el| el["jid"].to_s.start_with?("customer_") }
 	pass unless address
 
-	CustomerRepo.new.find(
-		Blather::JID.new(address["jid"].to_s).node.delete_prefix("customer_")
-	).then { |customer|
+	CustomerRepo.new.find_by_jid(address["jid"]).then { |customer|
 		m.from = m.from.with(domain: CONFIG[:component][:jid])
 		m.to = m.to.with(domain: customer.jid.domain)
 		address["jid"] = customer.jid.to_s
@@ -362,7 +366,9 @@ disco_items node: "http://jabber.org/protocol/commands" do |iq|
 	reply = iq.reply
 	reply.node = "http://jabber.org/protocol/commands"
 
-	CustomerRepo.new.find_by_jid(iq.from.stripped).catch {
+	CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new).find_by_jid(
+		iq.from.stripped
+	).catch {
 		nil
 	}.then { |customer|
 		CommandList.for(customer)
@@ -397,7 +403,8 @@ end
 Command.new(
 	"jabber:iq:register",
 	"Register",
-	list_for: ->(*) { true }
+	list_for: ->(*) { true },
+	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 ) {
 	Command.customer.catch {
 		Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Customer.create"))
@@ -542,7 +549,8 @@ Command.new(
 Command.new(
 	"info",
 	"Show Account Info",
-	list_for: ->(*) { true }
+	list_for: ->(*) { true },
+	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 ) {
 	Command.customer.then(&:info).then do |info|
 		Command.finish do |reply|
@@ -584,17 +592,18 @@ Command.new(
 Command.new(
 	"migrate billing",
 	"Switch from PayPal or expired trial to new billing",
-	list_for: ->(tel:, customer:, **) { tel && !customer&.currency }
+	list_for: ->(tel:, customer:, **) { tel && !customer&.currency },
+	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 ) {
 	EMPromise.all([
-		Command.customer.then { |c| EMPromise.all([c, c.registered?.then(&:phone)]) },
+		Command.customer,
 		Command.reply do |reply|
 			reply.allowed_actions = [:next]
 			reply.command << FormTemplate.render("migrate_billing")
 		end
-	]).then do |((customer, tel), iq)|
+	]).then do |(customer, iq)|
 		Registration::Payment.for(
-			iq, customer, tel,
+			iq, customer, customer.registered?.phone,
 			final_message: PaypalDone::MESSAGE,
 			finish: PaypalDone
 		).then(&:write).catch_only(Command::Execution::FinalStanza) do |s|

test/test_backend_sgx.rb 🔗

@@ -1,17 +1,16 @@
 # frozen_string_literal: true
 
 require "test_helper"
+require "bwmsgsv2_repo"
 require "backend_sgx"
+require "trivial_backend_sgx_repo"
 
 BackendSgx::IQ_MANAGER = Minitest::Mock.new
+Bwmsgsv2Repo::IQ_MANAGER = Minitest::Mock.new
 
 class BackendSgxTest < Minitest::Test
-	def setup
-		@sgx = BackendSgx.new("test")
-	end
-
 	def test_registered
-		BackendSgx::IQ_MANAGER.expect(
+		Bwmsgsv2Repo::IQ_MANAGER.expect(
 			:write,
 			EMPromise.resolve(IBR.new.tap { |ibr| ibr.registered = true }),
 			[Matching.new do |ibr|
@@ -19,12 +18,13 @@ class BackendSgxTest < Minitest::Test
 				assert_equal "customer_test@component", ibr.from.to_s
 			end]
 		)
-		assert @sgx.registered?.sync
+		sgx = Bwmsgsv2Repo.new(redis: FakeRedis.new).get("test").sync
+		assert sgx.registered?
 	end
 	em :test_registered
 
 	def test_registered_not_registered
-		BackendSgx::IQ_MANAGER.expect(
+		Bwmsgsv2Repo::IQ_MANAGER.expect(
 			:write,
 			EMPromise.resolve(IBR.new.tap { |ibr| ibr.registered = false }),
 			[Matching.new do |ibr|
@@ -32,7 +32,8 @@ class BackendSgxTest < Minitest::Test
 				assert_equal "customer_test@component", ibr.from.to_s
 			end]
 		)
-		refute @sgx.registered?.sync
+		sgx = Bwmsgsv2Repo.new(redis: FakeRedis.new).get("test").sync
+		refute sgx.registered?
 	end
 	em :test_registered_not_registered
 
@@ -48,7 +49,8 @@ class BackendSgxTest < Minitest::Test
 				assert_equal "+15555550000", ibr.phone
 			end]
 		)
-		@sgx.register!("+15555550000")
+		sgx = TrivialBackendSgxRepo.new.get("test")
+		sgx.register!("+15555550000")
 		BackendSgx::IQ_MANAGER.verify
 	end
 end

test/test_command_list.rb 🔗

@@ -6,6 +6,9 @@ require "command_list"
 
 CommandList::Customer = Minitest::Mock.new
 CommandList::REDIS = Minitest::Mock.new
+CustomerRepo::REDIS = Minitest::Mock.new
+CustomerRepo::DB = Minitest::Mock.new
+CustomerRepo::BRAINTREE = Minitest::Mock.new
 
 class CommandListTest < Minitest::Test
 	SETUP = begin
@@ -44,11 +47,6 @@ class CommandListTest < Minitest::Test
 	em :test_for_unregistered
 
 	def test_for_registered
-		CommandList::REDIS.expect(
-			:get,
-			EMPromise.resolve(nil),
-			["catapult_fwd-1"]
-		)
 		customer = OpenStruct.new(
 			registered?: OpenStruct.new(phone: "1"),
 			payment_methods: EMPromise.resolve([])
@@ -61,14 +59,10 @@ class CommandListTest < Minitest::Test
 	em :test_for_registered
 
 	def test_for_registered_with_fwd
-		CommandList::REDIS.expect(
-			:get,
-			EMPromise.resolve("tel:1"),
-			["catapult_fwd-1"]
-		)
 		customer = OpenStruct.new(
 			registered?: OpenStruct.new(phone: "1"),
-			payment_methods: EMPromise.resolve([])
+			payment_methods: EMPromise.resolve([]),
+			fwd: OpenStruct.new
 		)
 		assert_equal(
 			["no_customer", "registered", "fwd"],
@@ -78,11 +72,6 @@ class CommandListTest < Minitest::Test
 	em :test_for_registered_with_fwd
 
 	def test_for_registered_with_credit_card
-		CommandList::REDIS.expect(
-			:get,
-			EMPromise.resolve(nil),
-			["catapult_fwd-1"]
-		)
 		customer = OpenStruct.new(
 			registered?: OpenStruct.new(phone: "1"),
 			plan_name: "test",
@@ -96,11 +85,6 @@ class CommandListTest < Minitest::Test
 	em :test_for_registered_with_credit_card
 
 	def test_for_registered_with_currency
-		CommandList::REDIS.expect(
-			:get,
-			EMPromise.resolve(nil),
-			["catapult_fwd-1"]
-		)
 		customer = OpenStruct.new(
 			registered?: OpenStruct.new(phone: "1"),
 			currency: :USD

test/test_customer_info.rb 🔗

@@ -9,7 +9,7 @@ CustomerPlan::REDIS = Minitest::Mock.new
 class CustomerInfoTest < Minitest::Test
 	def test_info_does_not_crash
 		sgx = Minitest::Mock.new
-		sgx.expect(:registered?, EMPromise.resolve(nil))
+		sgx.expect(:registered?, false)
 
 		CustomerPlan::REDIS.expect(
 			:get,
@@ -25,7 +25,7 @@ class CustomerInfoTest < Minitest::Test
 
 	def test_admin_info_does_not_crash
 		sgx = Minitest::Mock.new
-		sgx.expect(:registered?, EMPromise.resolve(nil))
+		sgx.expect(:registered?, false)
 
 		CustomerPlan::REDIS.expect(
 			:get,
@@ -41,7 +41,7 @@ class CustomerInfoTest < Minitest::Test
 
 	def test_inactive_info_does_not_crash
 		sgx = Minitest::Mock.new
-		sgx.expect(:registered?, EMPromise.resolve(nil))
+		sgx.expect(:registered?, false)
 
 		CustomerPlan::REDIS.expect(
 			:get,
@@ -63,7 +63,7 @@ class CustomerInfoTest < Minitest::Test
 
 	def test_inactive_admin_info_does_not_crash
 		sgx = Minitest::Mock.new
-		sgx.expect(:registered?, EMPromise.resolve(nil))
+		sgx.expect(:registered?, false)
 
 		CustomerPlan::REDIS.expect(
 			:get,

test/test_customer_repo.rb 🔗

@@ -8,15 +8,15 @@ class CustomerRepoTest < Minitest::Test
 		# sgx-jmp customer
 		"jmp_customer_jid-test" => "test@example.com",
 		"jmp_customer_id-test@example.com" => "test",
-		"catapult_jid-+13334445555" => "customer_test@jmp.chat",
-		"catapult_cred-customer_test@jmp.chat" => [
+		"catapult_jid-+13334445555" => "customer_test@component",
+		"catapult_cred-customer_test@component" => [
 			"test_bw_customer", "", "", "+13334445555"
 		],
 		# sgx-jmp customer, empty DB
 		"jmp_customer_jid-empty" => "empty@example.com",
 		"jmp_customer_id-empty@example.com" => "empty",
-		"catapult_jid-+16667778888" => "customer_empty@jmp.chat",
-		"catapult_cred-customer_empty@jmp.chat" => [
+		"catapult_jid-+16667778888" => "customer_empty@component",
+		"catapult_cred-customer_empty@component" => [
 			"test_bw_customer", "", "", "+16667778888"
 		],
 		# v2 customer
@@ -75,7 +75,7 @@ class CustomerRepoTest < Minitest::Test
 	em :test_find_by_id
 
 	def test_find_by_customer_jid
-		customer = @repo.find_by_jid("customer_test@jmp.chat").sync
+		customer = @repo.find_by_jid("customer_test@component").sync
 		assert_kind_of Customer, customer
 		assert_equal 1234, customer.balance
 		assert_equal "merchant_usd", customer.merchant_account

test/test_helper.rb 🔗

@@ -168,10 +168,18 @@ class FakeRedis
 		set(key, value)
 	end
 
+	def mget(*keys)
+		EMPromise.all(keys.map(&method(:get)))
+	end
+
 	def get(key)
 		EMPromise.resolve(@values[key])
 	end
 
+	def getbit(key, bit)
+		get(key).then { |v| v.to_i.to_s(2)[bit].to_i }
+	end
+
 	def exists(*keys)
 		EMPromise.resolve(
 			@values.select { |k, _| keys.include? k }.size

test/test_registration.rb 🔗

@@ -20,7 +20,7 @@ end
 class RegistrationTest < Minitest::Test
 	def test_for_registered
 		sgx = OpenStruct.new(
-			registered?: EMPromise.resolve(OpenStruct.new(phone: "+15555550000"))
+			registered?: OpenStruct.new(phone: "+15555550000")
 		)
 		iq = Blather::Stanza::Iq::Command.new
 		iq.from = "test@example.com"
@@ -38,7 +38,7 @@ class RegistrationTest < Minitest::Test
 		web_manager = TelSelections.new(redis: FakeRedis.new)
 		web_manager.set("test@example.net", "+15555550000")
 		result = execute_command do
-			sgx = OpenStruct.new(registered?: EMPromise.resolve(nil))
+			sgx = OpenStruct.new(registered?: false)
 			Registration.for(
 				customer(
 					plan_name: "test_usd",
@@ -53,7 +53,7 @@ class RegistrationTest < Minitest::Test
 	em :test_for_activated
 
 	def test_for_not_activated_with_customer_id
-		sgx = OpenStruct.new(registered?: EMPromise.resolve(nil))
+		sgx = OpenStruct.new(registered?: false)
 		web_manager = TelSelections.new(redis: FakeRedis.new)
 		web_manager.set("test@example.net", "+15555550000")
 		iq = Blather::Stanza::Iq::Command.new
@@ -520,7 +520,7 @@ class RegistrationTest < Minitest::Test
 		BackendSgx::REDIS = Minitest::Mock.new
 
 		def setup
-			@sgx = Minitest::Mock.new(BackendSgx.new("test"))
+			@sgx = Minitest::Mock.new(TrivialBackendSgxRepo.new.get("test"))
 			iq = Blather::Stanza::Iq::Command.new
 			iq.from = "test\\40example.com@cheogram.com"
 			@finish = Registration::Finish.new(

web.rb 🔗

@@ -5,99 +5,12 @@ require "forwardable"
 require "roda"
 require "thin"
 require "sentry-ruby"
-require "bandwidth"
-
-Faraday.default_adapter = :em_synchrony
 
 require_relative "lib/cdr"
 require_relative "lib/roda_capture"
 require_relative "lib/roda_em_promise"
 require_relative "lib/rack_fiber"
 
-BANDWIDTH_VOICE = Bandwidth::Client.new(
-	voice_basic_auth_user_name: CONFIG[:creds][:username],
-	voice_basic_auth_password: CONFIG[:creds][:password]
-).voice_client.client
-
-module CustomerFwd
-	def self.from_redis(redis, customer, tel)
-		EMPromise.all([
-			redis.get("catapult_fwd-#{tel}"),
-			customer.fwd_timeout
-		]).then do |(fwd, stimeout)|
-			timeout = Timeout.new(stimeout)
-			next if !fwd || timeout.zero?
-			self.for(fwd, timeout)
-		end
-	end
-
-	def self.for(uri, timeout)
-		case uri
-		when /^tel:/
-			Tel.new(uri, timeout)
-		when /^sip:/
-			SIP.new(uri, timeout)
-		when /^xmpp:/
-			XMPP.new(uri, timeout)
-		else
-			raise "Unknown forward URI: #{uri}"
-		end
-	end
-
-	class Timeout
-		def initialize(s)
-			@timeout = s.nil? || s.to_i.negative? ? 300 : s.to_i
-		end
-
-		def zero?
-			@timeout.zero?
-		end
-
-		def to_i
-			@timeout
-		end
-	end
-
-	class Tel
-		attr_reader :timeout
-
-		def initialize(uri, timeout)
-			@tel = uri.sub(/^tel:/, "")
-			@timeout = timeout
-		end
-
-		def to
-			@tel
-		end
-	end
-
-	class SIP
-		attr_reader :timeout
-
-		def initialize(uri, timeout)
-			@uri = uri
-			@timeout = timeout
-		end
-
-		def to
-			@uri
-		end
-	end
-
-	class XMPP
-		attr_reader :timeout
-
-		def initialize(uri, timeout)
-			@jid = uri.sub(/^xmpp:/, "")
-			@timeout = timeout
-		end
-
-		def to
-			"sip:#{ERB::Util.url_encode(@jid)}@sip.cheogram.com"
-		end
-	end
-end
-
 # rubocop:disable Metrics/ClassLength
 class Web < Roda
 	use Rack::Fiber # Must go first!
@@ -112,9 +25,8 @@ class Web < Roda
 		attr_reader :customer_repo, :log
 		attr_reader :true_inbound_call, :outbound_transfers
 
-		def run(log, customer_repo, *listen_on)
+		def run(log, *listen_on)
 			plugin :common_logger, log, method: :info
-			@customer_repo = customer_repo
 			@true_inbound_call = {}
 			@outbound_transfers = {}
 			Thin::Logging.logger = log
@@ -127,8 +39,7 @@ class Web < Roda
 	end
 
 	extend Forwardable
-	def_delegators :'self.class', :customer_repo, :true_inbound_call,
-	               :outbound_transfers
+	def_delegators :'self.class', :true_inbound_call, :outbound_transfers
 	def_delegators :request, :params
 
 	def log
@@ -221,7 +132,7 @@ class Web < Roda
 								end
 							end
 
-							customer_repo.find_by_tel(params["to"]).then do |customer|
+							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
 								CDR.for_inbound(customer.customer_id, params).save
 							end
 						}.catch(&method(:log_error))
@@ -257,7 +168,7 @@ class Web < Roda
 								"https://jmp.chat"
 							)
 
-							customer_repo.find_by_tel(params["to"]).then do |customer|
+							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
 								m = Blather::Stanza::Message.new
 								m.chat_state = nil
 								m.from = from_jid
@@ -271,7 +182,7 @@ class Web < Roda
 						end
 
 						r.post "transcription" do
-							customer_repo.find_by_tel(params["to"]).then do |customer|
+							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
 								m = Blather::Stanza::Message.new
 								m.chat_state = nil
 								m.from = from_jid
@@ -286,19 +197,13 @@ class Web < Roda
 						end
 
 						r.post do
-							customer_repo
+							CustomerRepo
+								.new(sgx_repo: Bwmsgsv2Repo.new)
 								.find_by_tel(params["to"])
-								.then { |customer|
-									EMPromise.all([
-										customer.ogm(params["from"]),
-										customer.catapult_flag(
-											BackendSgx::VOICEMAIL_TRANSCRIPTION_DISABLED
-										)
-									])
-								}.then do |(ogm, transcription_disabled)|
+								.then do |customer|
 									render :voicemail, locals: {
-										ogm: ogm,
-										transcription_enabled: !transcription_disabled
+										ogm: customer.ogm(params["from"]),
+										transcription_enabled: customer.transcription_enabled
 									}
 								end
 						end
@@ -316,25 +221,25 @@ class Web < Roda
 						return render :pause, locals: { duration: 300 }
 					end
 
-					customer_repo.find_by_tel(params["to"]).then do |customer|
-						CustomerFwd.from_redis(::REDIS, customer, params["to"]).then do |fwd|
-							if fwd
-								body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
-									cc.to = fwd.to
-									cc.from = params["from"]
-									cc.application_id = params["applicationId"]
-									cc.call_timeout = fwd.timeout.to_i
-									cc.answer_url = url inbound_calls_path(nil)
-									cc.disconnect_url = url inbound_calls_path(:transfer_complete)
-								end
-								true_inbound_call[pseudo_call_id] = params["callId"]
-								outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
-									CONFIG[:creds][:account], body: body
-								).data.call_id
-								render :pause, locals: { duration: 300 }
-							else
-								render :redirect, locals: { to: inbound_calls_path(:voicemail) }
+					CustomerRepo.new(
+						sgx_repo: Bwmsgsv2Repo.new
+					).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
+						if fwd
+							body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
+								cc.to = fwd.to
+								cc.from = params["from"]
+								cc.application_id = params["applicationId"]
+								cc.call_timeout = fwd.timeout.to_i
+								cc.answer_url = url inbound_calls_path(nil)
+								cc.disconnect_url = url inbound_calls_path(:transfer_complete)
 							end
+							true_inbound_call[pseudo_call_id] = params["callId"]
+							outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
+								CONFIG[:creds][:account], body: body
+							).data.call_id
+							render :pause, locals: { duration: 300 }
+						else
+							render :redirect, locals: { to: inbound_calls_path(:voicemail) }
 						end
 					end
 				end
@@ -353,9 +258,9 @@ class Web < Roda
 
 				r.post do
 					customer_id = params["from"].sub(/^\+1/, "")
-					customer_repo.find(customer_id).then(:registered?).then do |reg|
+					CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new).find(customer_id).then do |c|
 						render :forward, locals: {
-							from: reg.phone,
+							from: c.registered?.phone,
 							to: params["to"]
 						}
 					end