~singpolyma/sgx-jmp

497b442bba246370bd5b724b5c2fa547ceb61ce6 — Christopher Vollick 3 years ago 6ead876
Customer Info

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.
M .rubocop.yml => .rubocop.yml +3 -0
@@ 51,6 51,9 @@ Style/DoubleNegation:
  EnforcedStyle: allowed_in_returns
  Enabled: false

Style/PerlBackrefs:
  Enabled: false

Style/RegexpLiteral:
  EnforcedStyle: slashes
  AllowInnerSlashes: true

M config-schema.dhall => config-schema.dhall +2 -0
@@ 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 }
}

M config.dhall.sample => config.dhall.sample +3 -1
@@ 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"
}

A lib/api.rb => lib/api.rb +52 -0
@@ 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

M lib/customer.rb => lib/customer.rb +19 -0
@@ 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

A lib/customer_info.rb => lib/customer_info.rb +87 -0
@@ 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

A lib/customer_info_form.rb => lib/customer_info_form.rb +82 -0
@@ 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

M lib/customer_repo.rb => lib/customer_repo.rb +23 -3
@@ 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(

A lib/legacy_customer.rb => lib/legacy_customer.rb +65 -0
@@ 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

A lib/proxied_jid.rb => lib/proxied_jid.rb +28 -0
@@ 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

M sgx_jmp.rb => sgx_jmp.rb +44 -0
@@ 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

A test/test_customer_info.rb => test/test_customer_info.rb +114 -0
@@ 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

A test/test_customer_info_form.rb => test/test_customer_info_form.rb +117 -0
@@ 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

M test/test_customer_repo.rb => test/test_customer_repo.rb +102 -42
@@ 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


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

A test/test_proxied_jid.rb => test/test_proxied_jid.rb +34 -0
@@ 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