Customer Info

Christopher Vollick created

This should allow us, the admins, to query information about a customer
without having to dive in and run a couple redis queries and some
database queries before getting the full picture of who we're talking
to.

It also allows the users to request some data about themselves. Balance and
phone number are already visible in other places, but their expiry is currently
not, and people have been asking about it.

Change summary

.rubocop.yml                    |   3 
config-schema.dhall             |   2 
config.dhall.sample             |   4 
lib/api.rb                      |  52 ++++++++++++
lib/customer.rb                 |  19 ++++
lib/customer_info.rb            |  87 +++++++++++++++++++++
lib/customer_info_form.rb       |  82 +++++++++++++++++++
lib/customer_repo.rb            |  26 +++++
lib/legacy_customer.rb          |  65 +++++++++++++++
lib/proxied_jid.rb              |  28 ++++++
sgx_jmp.rb                      |  44 ++++++++++
test/test_customer_info.rb      | 114 +++++++++++++++++++++++++++
test/test_customer_info_form.rb | 117 ++++++++++++++++++++++++++++
test/test_customer_repo.rb      | 144 ++++++++++++++++++++++++----------
test/test_helper.rb             |  33 +++++++
test/test_proxied_jid.rb        |  34 ++++++++
16 files changed, 807 insertions(+), 47 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -51,6 +51,9 @@ Style/DoubleNegation:
   EnforcedStyle: allowed_in_returns
   Enabled: false
 
+Style/PerlBackrefs:
+  Enabled: false
+
 Style/RegexpLiteral:
   EnforcedStyle: slashes
   AllowInnerSlashes: true

config-schema.dhall 🔗

@@ -1,4 +1,5 @@
 { activation_amount : Natural
+, admins : List Text
 , adr : Text
 , bandwidth_peer : Text
 , bandwidth_site : Text
@@ -41,6 +42,7 @@
 , server : { host : Text, port : Natural }
 , sgx : Text
 , sip_host : Text
+, upstream_domain : Text
 , web_register : { from : Text, to : Text }
 , xep0157 : List { label : Text, value : Text, var : Text }
 }

config.dhall.sample 🔗

@@ -71,5 +71,7 @@
 	adr = "",
 	interac = "",
 	payable = "",
-	notify_from = "+15551234567@example.net"
+	notify_from = "+15551234567@example.net",
+	admins = ["test\\40example.com@example.net"],
+	upstream_domain = "example.net"
 }

lib/api.rb 🔗

@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class API
+	def self.for(customer)
+		EMPromise.all([
+			sgx_jmp?(customer),
+			api_version(customer)
+		]).then do |is_jmp, api|
+			is_jmp ? JMP.new : api
+		end
+	end
+
+	def self.sgx_jmp?(customer)
+		key = "catapult_cred-customer_#{customer.customer_id}@jmp.chat"
+		REDIS.exists(key).then { |is_sgx| is_sgx == 1 }
+	end
+
+	def self.api_version(customer)
+		REDIS.lindex("catapult_cred-#{customer.jid}", 0).then do |api|
+			case api
+			when CONFIG.dig(:catapult, :user)
+				V1.new
+			when CONFIG.dig(:creds, :account)
+				V2.new
+			else
+				new
+			end
+		end
+	end
+
+	class V1 < API
+		def to_s
+			"v1"
+		end
+	end
+
+	class V2 < API
+		def to_s
+			"v2"
+		end
+	end
+
+	class JMP < V2
+		def to_s
+			"sgx-jmp"
+		end
+	end
+
+	def to_s
+		"not JMP"
+	end
+end

lib/customer.rb 🔗

@@ -2,13 +2,16 @@
 
 require "forwardable"
 
+require_relative "./api"
 require_relative "./blather_ext"
+require_relative "./customer_info"
 require_relative "./customer_plan"
 require_relative "./customer_usage"
 require_relative "./backend_sgx"
 require_relative "./ibr"
 require_relative "./payment_methods"
 require_relative "./plan"
+require_relative "./proxied_jid"
 require_relative "./sip_account"
 
 class Customer
@@ -95,5 +98,21 @@ class Customer
 		end
 	end
 
+	def admin?
+		CONFIG[:admins].include?(jid.to_s)
+	end
+
+	def api
+		API.for(self)
+	end
+
+	def admin_info
+		AdminInfo.for(self, @plan, expires_at)
+	end
+
+	def info
+		CustomerInfo.for(self, @plan, expires_at)
+	end
+
 	protected def_delegator :@plan, :expires_at
 end

lib/customer_info.rb 🔗

@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+require_relative "proxied_jid"
+require_relative "customer_plan"
+
+class CustomerInfo
+	value_semantics do
+		plan CustomerPlan
+		tel Either(String, nil)
+		balance BigDecimal
+		expires_at Either(Time, nil)
+	end
+
+	def self.for(customer, plan, expires_at)
+		customer.registered?.then do |registration|
+			new(
+				plan: plan,
+				tel: registration&.phone,
+				balance: customer.balance,
+				expires_at: expires_at
+			)
+		end
+	end
+
+	def account_status
+		if plan.plan_name.nil?
+			"Transitional"
+		elsif plan.active?
+			"Active"
+		else
+			"Expired"
+		end
+	end
+
+	def next_renewal
+		{ var: "Next renewal", value: expires_at.strftime("%Y-%m-%d") } if expires_at
+	end
+
+	def fields
+		[
+			{ var: "Account Status", value: account_status },
+			{ var: "Phone Number", value: tel || "Not Registered" },
+			{ var: "Balance", value: "$%.4f" % balance },
+			next_renewal
+		].compact
+	end
+end
+
+class AdminInfo
+	value_semantics do
+		jid ProxiedJID, coerce: ProxiedJID.method(:new)
+		customer_id String
+		info CustomerInfo
+		api API
+	end
+
+	def self.for(customer, plan, expires_at)
+		EMPromise.all([
+			CustomerInfo.for(customer, plan, expires_at),
+			customer.api
+		]).then do |info, api_value|
+			new(
+				jid: customer.jid,
+				customer_id: customer.customer_id,
+				info: info, api: api_value
+			)
+		end
+	end
+
+	def plan_fields
+		[
+			{ var: "Plan", value: info.plan.plan_name || "No Plan" },
+			{ var: "Currency", value: (info.plan.currency || "No Currency").to_s }
+		]
+	end
+
+	def fields
+		info.fields + [
+			{ var: "JID", value: jid.unproxied.to_s },
+			{ var: "Cheo JID", value: jid.to_s },
+			{ var: "Customer ID", value: customer_id },
+			*plan_fields,
+			{ var: "API", value: api.to_s }
+		]
+	end
+end

lib/customer_info_form.rb 🔗

@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require_relative "customer_repo"
+require_relative "proxied_jid"
+require_relative "legacy_customer"
+
+class CustomerInfoForm
+	def initialize(customer_repo=CustomerRepo.new)
+		@customer_repo = customer_repo
+	end
+
+	def picker_form
+		form = Blather::Stanza::X.new(:form)
+		form.title = "Pick Customer"
+		form.instructions = "Tell us something about the customer and we'll try " \
+			"to get more information for you"
+
+		form.fields = {
+			var: "q", type: "text-single",
+			label: "Something about the customer",
+			description: "Supported things include: customer ID, JID, phone number"
+		}
+
+		form
+	end
+
+	def find_customer(response)
+		parse_something(response.form.field("q").value)
+	end
+
+	class NoCustomer
+		class AdminInfo
+			def fields
+				[{ var: "Account Status", value: "Not Found" }]
+			end
+		end
+
+		def admin_info
+			AdminInfo.new
+		end
+	end
+
+	def parse_something(value)
+		parser = Parser.new(@customer_repo)
+
+		EMPromise.all([
+			parser.as_customer_id(value),
+			parser.as_jid(value),
+			parser.as_phone(value),
+			EMPromise.resolve(NoCustomer.new)
+		]).then { |approaches| approaches.compact.first }
+	end
+
+	class Parser
+		def initialize(customer_repo)
+			@customer_repo = customer_repo
+		end
+
+		def as_customer_id(value)
+			@customer_repo.find(value).catch { nil }
+		end
+
+		def as_cheo(value)
+			ProxiedJID.proxy(Blather::JID.new(value))
+		end
+
+		def as_jid(value)
+			EMPromise.all([
+				@customer_repo.find_by_jid(value).catch { nil },
+				@customer_repo.find_by_jid(as_cheo(value)).catch { nil }
+			]).then { |approaches| approaches.compact.first }
+		end
+
+		def as_phone(value)
+			unless value.gsub(/[^0-9]/, "") =~ /^\+?1?(\d{10})$/
+				return EMPromise.resolve(nil)
+			end
+
+			@customer_repo.find_by_tel("+1#{$1}").catch { nil }
+		end
+	end
+end

lib/customer_repo.rb 🔗

@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 require_relative "customer"
+require_relative "legacy_customer"
 require_relative "polyfill"
 
 class CustomerRepo
@@ -18,9 +19,21 @@ class CustomerRepo
 	end
 
 	def find_by_jid(jid)
-		@redis.get("jmp_customer_id-#{jid}").then do |customer_id|
-			raise "No customer id" unless customer_id
-			find_inner(customer_id, jid)
+		if jid.to_s =~ /\Acustomer_(.+)@jmp.chat\Z/
+			find($1)
+		else
+			@redis.get("jmp_customer_id-#{jid}").then { |customer_id|
+				raise "No customer id" unless customer_id
+				find_inner(customer_id, jid)
+			}.catch do
+				find_legacy_customer(jid)
+			end
+		end
+	end
+
+	def find_by_tel(tel)
+		@redis.get("catapult_jid-#{tel}").then do |jid|
+			find_by_jid(jid)
 		end
 	end
 
@@ -39,6 +52,13 @@ class CustomerRepo
 
 protected
 
+	def find_legacy_customer(jid)
+		@redis.lindex("catapult_cred-#{jid}", 3).then do |tel|
+			raise "No customer" unless tel
+			LegacyCustomer.new(Blather::JID.new(jid), tel)
+		end
+	end
+
 	def hydrate_plan(customer_id, raw_customer)
 		raw_customer.dup.tap do |data|
 			data[:plan] = CustomerPlan.new(

lib/legacy_customer.rb 🔗

@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+require_relative "proxied_jid"
+
+class LegacyCustomer
+	attr_reader :jid, :tel
+
+	def initialize(jid, tel)
+		@jid = jid
+		@tel = tel
+	end
+
+	def customer_id
+		nil
+	end
+
+	def info
+		EMPromise.resolve(nil).then do
+			Info.new(jid: jid, tel: tel)
+		end
+	end
+
+	def admin_info
+		EMPromise.all([
+			info,
+			api
+		]).then do |info, api|
+			AdminInfo.new(info: info, api: api)
+		end
+	end
+
+	def api
+		API.for(self)
+	end
+
+	class Info
+		value_semantics do
+			jid ProxiedJID, coerce: ProxiedJID.method(:new)
+			tel String
+		end
+
+		def fields
+			[
+				{ var: "JID", value: jid.unproxied.to_s },
+				{ var: "Phone Number", value: tel }
+			]
+		end
+	end
+
+	class AdminInfo
+		value_semantics do
+			info Info
+			api API
+		end
+
+		def fields
+			info.fields + [
+				{ var: "Account Status", value: "Legacy" },
+				{ var: "Cheo JID", value: info.jid.to_s },
+				{ var: "API", value: api.to_s }
+			]
+		end
+	end
+end

lib/proxied_jid.rb 🔗

@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require "delegate"
+require "blather"
+
+class ProxiedJID < SimpleDelegator
+	ESCAPED = /20|22|26|27|2f|3a|3c|3e|40|5c/
+	def unproxied
+		Blather::JID.new(
+			node.gsub(/\\(#{ESCAPED})/) { |s|
+				s[1..-1].to_i(16).chr
+			}
+		)
+	end
+
+	def self.proxy(jid, suffix=CONFIG[:upstream_domain])
+		ProxiedJID.new(
+			Blather::JID.new(
+				jid.stripped.to_s
+					.gsub(/([ "&'\/:<>@]|\\(?=#{ESCAPED}))/) { |s|
+						"\\#{s.ord.to_s(16)}"
+					},
+				suffix,
+				jid.resource
+			)
+		)
+	end
+end

sgx_jmp.rb 🔗

@@ -68,6 +68,7 @@ require_relative "lib/buy_account_credit_form"
 require_relative "lib/command"
 require_relative "lib/command_list"
 require_relative "lib/customer"
+require_relative "lib/customer_info_form"
 require_relative "lib/customer_repo"
 require_relative "lib/electrum"
 require_relative "lib/expiring_lock"
@@ -101,6 +102,8 @@ def new_sentry_hub(stanza, name: nil)
 	hub
 end
 
+class AuthError < StandardError; end
+
 # Braintree is not async, so wrap in EM.defer for now
 class AsyncBraintree
 	def initialize(environment:, merchant_id:, public_key:, private_key:, **)
@@ -553,6 +556,47 @@ command :execute?, node: "web-register", sessionid: nil do |iq|
 	end
 end
 
+Command.new(
+	"info",
+	"Show Account Info",
+	list_for: ->(*) { true }
+) {
+	Command.customer.then(&:info).then do |info|
+		Command.finish do |reply|
+			form = Blather::Stanza::X.new(:result)
+			form.title = "Account Info"
+			form.fields = info.fields
+			reply.command << form
+		end
+	end
+}.register(self).then(&CommandList.method(:register))
+
+Command.new(
+	"customer info",
+	"Show Customer Info",
+	list_for: ->(customer: nil, **) { customer&.admin? }
+) {
+	Command.customer.then do |customer|
+		raise AuthError, "You are not an admin" unless customer&.admin?
+
+		customer_info = CustomerInfoForm.new
+		Command.reply { |reply|
+			reply.command << customer_info.picker_form
+		}.then { |response|
+			customer_info.find_customer(response)
+		}.then do |target_customer|
+			target_customer.admin_info.then do |info|
+				Command.finish do |reply|
+					form = Blather::Stanza::X.new(:result)
+					form.title = "Customer Info"
+					form.fields = info.fields
+					reply.command << form
+				end
+			end
+		end
+	end
+}.register(self).then(&CommandList.method(:register))
+
 command sessionid: /./ do |iq|
 	COMMAND_MANAGER.fulfill(iq)
 end

test/test_customer_info.rb 🔗

@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+API::REDIS = Minitest::Mock.new
+
+class CustomerInfoTest < Minitest::Test
+	def test_info_does_not_crash
+		sgx = Minitest::Mock.new
+		sgx.expect(:registered?, EMPromise.resolve(nil))
+
+		cust = customer(sgx: sgx)
+		assert cust.info.sync.fields
+		assert_mock sgx
+	end
+	em :test_info_does_not_crash
+
+	def test_admin_info_does_not_crash
+		sgx = Minitest::Mock.new
+		sgx.expect(:registered?, EMPromise.resolve(nil))
+
+		API::REDIS.expect(
+			:exists,
+			EMPromise.resolve(nil),
+			["catapult_cred-customer_test@jmp.chat"]
+		)
+
+		API::REDIS.expect(
+			:lindex,
+			EMPromise.resolve(nil),
+			["catapult_cred-test@example.net", 0]
+		)
+
+		cust = customer(sgx: sgx)
+		assert cust.admin_info.sync.fields
+		assert_mock sgx
+	end
+	em :test_admin_info_does_not_crash
+
+	def test_inactive_info_does_not_crash
+		sgx = Minitest::Mock.new
+		sgx.expect(:registered?, EMPromise.resolve(nil))
+
+		plan = CustomerPlan.new("test", plan: nil, expires_at: nil)
+		cust = Customer.new(
+			"test",
+			Blather::JID.new("test@example.net"),
+			plan: plan,
+			sgx: sgx
+		)
+		assert cust.info.sync.fields
+		assert_mock sgx
+	end
+	em :test_inactive_info_does_not_crash
+
+	def test_inactive_admin_info_does_not_crash
+		sgx = Minitest::Mock.new
+		sgx.expect(:registered?, EMPromise.resolve(nil))
+
+		API::REDIS.expect(
+			:exists,
+			EMPromise.resolve(nil),
+			["catapult_cred-customer_test@jmp.chat"]
+		)
+
+		API::REDIS.expect(
+			:lindex,
+			EMPromise.resolve(nil),
+			["catapult_cred-test@example.net", 0]
+		)
+
+		plan = CustomerPlan.new("test", plan: nil, expires_at: nil)
+		cust = Customer.new(
+			"test",
+			Blather::JID.new("test@example.net"),
+			plan: plan,
+			sgx: sgx
+		)
+
+		assert cust.admin_info.sync.fields
+		assert_mock sgx
+	end
+	em :test_inactive_admin_info_does_not_crash
+
+	def test_legacy_customer_info_does_not_crash
+		cust = LegacyCustomer.new(
+			Blather::JID.new("legacy@example.com"),
+			"+12223334444"
+		)
+		assert cust.info.sync.fields
+	end
+	em :test_legacy_customer_info_does_not_crash
+
+	def test_legacy_customer_admin_info_does_not_crash
+		API::REDIS.expect(
+			:exists,
+			EMPromise.resolve(nil),
+			["catapult_cred-customer_@jmp.chat"]
+		)
+
+		API::REDIS.expect(
+			:lindex,
+			EMPromise.resolve(nil),
+			["catapult_cred-legacy@example.com", 0]
+		)
+
+		cust = LegacyCustomer.new(
+			Blather::JID.new("legacy@example.com"),
+			"+12223334444"
+		)
+		assert cust.admin_info.sync.fields
+	end
+	em :test_legacy_customer_admin_info_does_not_crash
+end

test/test_customer_info_form.rb 🔗

@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "forwardable"
+require "customer_info_form"
+require "customer_repo"
+
+class FakeRepo
+	def initialize(customers)
+		@customers = customers
+	end
+
+	def find(id)
+		EMPromise.resolve(nil).then do
+			@customers.find { |cust| cust.customer_id == id } || raise("No Customer")
+		end
+	end
+
+	def find_by_jid(jid)
+		EMPromise.resolve(nil).then do
+			@customers.find { |cust| cust.jid.to_s == jid.to_s } || raise("No Customer")
+		end
+	end
+
+	def find_by_tel(tel)
+		EMPromise.resolve(nil).then do
+			@customers.find { |cust| cust.tel == tel } || raise("No Customer")
+		end
+	end
+end
+
+class CustomerInfoFormTest < Minitest::Test
+	def setup
+		@customer_test = OpenStruct.new(
+			customer_id: "test",
+			jid: "test\\40example.com@example.net",
+			tel: "+13334445555"
+		)
+		@customer_v2 = OpenStruct.new(
+			customer_id: "test_v2",
+			jid: "test_v2\\40example.com@example.net",
+			tel: "+14445556666"
+		)
+		@repo = FakeRepo.new([@customer_test, @customer_v2])
+		@info_form = CustomerInfoForm.new(@repo)
+	end
+
+	def test_nothing
+		assert_kind_of(
+			CustomerInfoForm::NoCustomer,
+			@info_form.parse_something("").sync
+		)
+	end
+	em :test_nothing
+
+	def test_find_customer_id
+		result = @info_form.parse_something("test").sync
+		assert_equal @customer_test, result
+	end
+	em :test_find_customer_id
+
+	def test_find_real_jid
+		result = @info_form.parse_something("test@example.com").sync
+		assert_equal @customer_test, result
+	end
+	em :test_find_real_jid
+
+	def test_find_cheo_jid
+		result = @info_form.parse_something(
+			"test\\40example.com@example.net"
+		).sync
+		assert_equal @customer_test, result
+	end
+	em :test_find_cheo_jid
+
+	def test_find_sgx_jmp_customer_by_phone
+		result = @info_form.parse_something("+13334445555").sync
+		assert_equal @customer_test, result
+	end
+	em :test_find_sgx_jmp_customer_by_phone
+
+	def test_find_sgx_jmp_customer_by_phone_friendly_format
+		result = @info_form.parse_something("13334445555").sync
+		assert_equal @customer_test, result
+
+		result = @info_form.parse_something("3334445555").sync
+		assert_equal @customer_test, result
+
+		result = @info_form.parse_something("(333) 444-5555").sync
+		assert_equal @customer_test, result
+	end
+	em :test_find_sgx_jmp_customer_by_phone_friendly_format
+
+	def test_find_v2_customer_by_phone
+		result = @info_form.parse_something("+14445556666").sync
+		assert_equal @customer_v2, result
+	end
+	em :test_find_v2_customer_by_phone
+
+	def test_missing_customer_by_phone
+		result = @info_form.parse_something("+17778889999").sync
+		assert_kind_of(
+			CustomerInfoForm::NoCustomer,
+			result
+		)
+	end
+	em :test_missing_customer_by_phone
+
+	def test_garbage
+		result = @info_form.parse_something("garbage").sync
+		assert_kind_of(
+			CustomerInfoForm::NoCustomer,
+			result
+		)
+	end
+	em :test_garbage
+end

test/test_customer_repo.rb 🔗

@@ -4,69 +4,129 @@ require "test_helper"
 require "customer_repo"
 
 class CustomerRepoTest < Minitest::Test
+	FAKE_REDIS = FakeRedis.new(
+		# 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" => [
+			"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" => [
+			"test_bw_customer", "", "", "+16667778888"
+		],
+		# v2 customer
+		"jmp_customer_jid-test_v2" => "test_v2@example.com",
+		"jmp_customer_id-test_v2@example.com" => "test_v2",
+		"catapult_jid-+14445556666" => "test_v2@example.com",
+		"catapult_cred-test_v2@example.com" => [
+			"test_bw_customer", "", "", "+14445556666"
+		],
+		# legacy customer
+		"catapult_cred-legacy@example.com" => [
+			"catapult_user", "", "", "+12223334444"
+		],
+		"catapult_jid-+12223334444" => "legacy@example.com"
+	)
+
+	FAKE_DB = FakeDB.new(
+		["test"] => [{
+			"balance" => BigDecimal(1234),
+			"plan_name" => "test_usd",
+			"expires_at" => Time.now + 100
+		}],
+		["test_v2"] => [{
+			"balance" => BigDecimal(2345),
+			"plan_name" => "test_usd",
+			"expires_at" => Time.now + 100
+		}]
+	)
+
 	def mkrepo(
-		redis: Minitest::Mock.new,
-		db: Minitest::Mock.new,
+		redis: FAKE_REDIS,
+		db: FAKE_DB,
 		braintree: Minitest::Mock.new
 	)
 		CustomerRepo.new(redis: redis, db: db, braintree: braintree)
 	end
 
+	def setup
+		@repo = mkrepo
+	end
+
 	def test_find_by_jid
-		redis = Minitest::Mock.new
-		db = Minitest::Mock.new
-		repo = mkrepo(redis: redis, db: db)
-		redis.expect(
-			:get,
-			EMPromise.resolve(1),
-			["jmp_customer_id-test@example.com"]
-		)
-		db.expect(
-			:query_defer,
-			EMPromise.resolve([{ balance: 1234, plan_name: "test_usd" }]),
-			[String, [1]]
-		)
-		customer = repo.find_by_jid("test@example.com").sync
+		customer = @repo.find_by_jid("test@example.com").sync
 		assert_kind_of Customer, customer
 		assert_equal 1234, customer.balance
 		assert_equal "merchant_usd", customer.merchant_account
-		assert_mock redis
-		assert_mock db
 	end
 	em :test_find_by_jid
 
+	def test_find_by_id
+		customer = @repo.find("test").sync
+		assert_kind_of Customer, customer
+		assert_equal 1234, customer.balance
+		assert_equal "merchant_usd", customer.merchant_account
+	end
+	em :test_find_by_id
+
+	def test_find_by_customer_jid
+		customer = @repo.find_by_jid("customer_test@jmp.chat").sync
+		assert_kind_of Customer, customer
+		assert_equal 1234, customer.balance
+		assert_equal "merchant_usd", customer.merchant_account
+	end
+	em :test_find_by_customer_jid
+
 	def test_find_by_jid_not_found
-		redis = Minitest::Mock.new
-		repo = mkrepo(redis: redis)
-		redis.expect(
-			:get,
-			EMPromise.resolve(nil),
-			["jmp_customer_id-test2@example.com"]
-		)
 		assert_raises do
-			repo.find_by_jid("test2@example.com").sync
+			@repo.find_by_jid("test2@example.com").sync
 		end
-		assert_mock redis
 	end
 	em :test_find_by_jid_not_found
 
+	def test_find_legacy_customer
+		customer = @repo.find_by_jid("legacy@example.com").sync
+		assert_kind_of LegacyCustomer, customer
+		assert_equal "+12223334444", customer.tel
+	end
+	em :test_find_legacy_customer
+
+	def test_find_sgx_customer_by_phone
+		customer = @repo.find_by_tel("+13334445555").sync
+		assert_kind_of Customer, customer
+		assert_equal "test", customer.customer_id
+	end
+	em :test_find_sgx_customer_by_phone
+
+	def test_find_v2_customer_by_phone
+		customer = @repo.find_by_tel("+14445556666").sync
+		assert_kind_of Customer, customer
+		assert_equal "test_v2", customer.customer_id
+	end
+	em :test_find_v2_customer_by_phone
+
+	def test_find_legacy_customer_by_phone
+		customer = @repo.find_by_tel("+12223334444").sync
+		assert_kind_of LegacyCustomer, customer
+		assert_equal "legacy@example.com", customer.jid.to_s
+	end
+	em :test_find_legacy_customer_by_phone
+
+	def test_find_missing_phone
+		assert_raises do
+			@repo.find_by_tel("+15556667777").sync
+		end
+	end
+	em :test_find_missing_phone
+
 	def test_find_db_empty
-		db = Minitest::Mock.new
-		redis = Minitest::Mock.new
-		redis.expect(
-			:get,
-			EMPromise.resolve("test@example.net"),
-			["jmp_customer_jid-7357"]
-		)
-		repo = mkrepo(db: db, redis: redis)
-		db.expect(
-			:query_defer,
-			EMPromise.resolve([]),
-			[String, [7357]]
-		)
-		customer = repo.find(7357).sync
+		customer = @repo.find("empty").sync
 		assert_equal BigDecimal(0), customer.balance
-		assert_mock db
 	end
 	em :test_find_db_empty
 

test/test_helper.rb 🔗

@@ -94,7 +94,8 @@ CONFIG = {
 		}
 	},
 	credit_card_url: ->(*) { "http://creditcard.example.com" },
-	electrum_notify_url: ->(*) { "http://notify.example.com" }
+	electrum_notify_url: ->(*) { "http://notify.example.com" },
+	upstream_domain: "example.net"
 }.freeze
 
 def panic(e)
@@ -131,6 +132,36 @@ class PromiseMock < Minitest::Mock
 	end
 end
 
+class FakeRedis
+	def initialize(values)
+		@values = values
+	end
+
+	def get(key)
+		EMPromise.resolve(@values[key])
+	end
+
+	def exists(*keys)
+		EMPromise.resolve(
+			@values.select { |k, _| keys.include? k }.size
+		)
+	end
+
+	def lindex(key, index)
+		get(key).then { |v| v&.fetch(index) }
+	end
+end
+
+class FakeDB
+	def initialize(items)
+		@items = items
+	end
+
+	def query_defer(_, args)
+		EMPromise.resolve(@items.fetch(args, []))
+	end
+end
+
 module EventMachine
 	class << self
 		# Patch EM.add_timer to be instant in tests

test/test_proxied_jid.rb 🔗

@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "proxied_jid"
+
+class ProxiedJIDTest < Minitest::Test
+	def test_unproxied
+		jid = ProxiedJID.new(Blather::JID.new("test\\40example.com@example.net"))
+		assert_equal "test@example.com", jid.unproxied.to_s
+	end
+
+	def test_proxied
+		jid = ProxiedJID.proxy(Blather::JID.new("test@example.com"))
+		assert_equal "test\\40example.com@example.net", jid.to_s
+	end
+
+	def test_escape
+		jid = ProxiedJID.proxy(Blather::JID.new("test \"&'/:<>", "example.com"))
+		assert_equal(
+			"test\\20\\22\\26\\27\\2f\\3a\\3c\\3e\\40example.com@example.net",
+			jid.to_s
+		)
+	end
+
+	def test_backlash_necessary
+		jid = ProxiedJID.proxy(Blather::JID.new("moop\\27@example.com"))
+		assert_equal "moop\\5c27\\40example.com@example.net", jid.to_s
+	end
+
+	def test_backslash_unnecessary
+		jid = ProxiedJID.proxy(Blather::JID.new("moop\\things@example.com"))
+		assert_equal "moop\\things\\40example.com@example.net", jid.to_s
+	end
+end