~singpolyma/sgx-jmp

9c5695ebc97b0c94571f094bcdc3d48bbba20232 — Stephen Paul Weber 3 years ago 4be555d + d719f18
Merge branch 'register-command-first-pass'

* register-command-first-pass:
  Add sourcehut CI
  Add Rakefile to run all tests
  Initial registration flow for Bitcoin
  Registrations that start on the web will have a tel selected already
  Helpers for doing Electrum RPC
  Add helper to fetch current BTC sell prices
  Allow skipping tests without being caught in pry
A .builds/debian-stable.yml => .builds/debian-stable.yml +22 -0
@@ 0,0 1,22 @@
image: debian/stable
sources:
- https://git.sr.ht/~singpolyma/sgx-jmp
packages:
- ruby
- ruby-dev
- bundler
- libxml2-dev
- libpq-dev
- rubocop
environment:
  LANG: C.UTF-8
tasks:
- dependencies: |
    cd sgx-jmp
    bundle install --without=development --path=.gems
- lint: |
    cd sgx-jmp
    rubocop
- test: |
    cd sgx-jmp
    RANTLY_COUNT=100 bundle exec rake test

M .rubocop.yml => .rubocop.yml +4 -0
@@ 10,6 10,10 @@ Metrics/MethodLength:
  Exclude:
    - test/*

Metrics/AbcSize:
  Exclude:
    - test/*

Style/Tab:
  Enabled: false


M Gemfile => Gemfile +2 -0
@@ 6,9 6,11 @@ gem "blather", git: "https://github.com/singpolyma/blather.git", branch: "ergono
gem "braintree"
gem "dhall"
gem "em-hiredis"
gem "em-http-request"
gem "em-pg-client", git: "https://github.com/royaltm/ruby-em-pg-client"
gem "em_promise.rb"
gem "eventmachine"
gem "money-open-exchange-rates"

group(:development) do
	gem "pry-reload"

A Rakefile => Rakefile +22 -0
@@ 0,0 1,22 @@
# frozen_string_literal: true

require "rake/testtask"
require "rubocop/rake_task"

Rake::TestTask.new(:test) do |t|
	ENV["RANTLY_VERBOSE"] = "0" unless ENV["RANTLY_VERBOSE"]
	ENV["RANTLY_COUNT"] = "10" unless ENV["RANTLY_COUNT"]

	t.libs << "test"
	t.libs << "lib"
	t.test_files = FileList["test/**/test_*.rb"]
	t.warning = false
end

RuboCop::RakeTask.new(:lint)

task :entr do
	sh "sh", "-c", "git ls-files | entr -s 'rubocop && rake test'"
end

task default: :test

A lib/btc_sell_prices.rb => lib/btc_sell_prices.rb +57 -0
@@ 0,0 1,57 @@
# frozen_string_literal: true

require "em-http"
require "money/bank/open_exchange_rates_bank"
require "nokogiri"

require_relative "em"

class BTCSellPrices
	def initialize(redis, oxr_app_id)
		@redis = redis
		@oxr = Money::Bank::OpenExchangeRatesBank.new(
			Money::RatesStore::Memory.new
		)
		@oxr.app_id = oxr_app_id
	end

	def cad
		fetch_canadianbitcoins.then do |http|
			canadianbitcoins = Nokogiri::HTML.parse(http.response)

			bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr")
			raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin"

			BigDecimal.new(
				bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
			)
		end
	end

	def usd
		EMPromise.all([cad, cad_to_usd]).then { |(a, b)| a * b }
	end

protected

	def fetch_canadianbitcoins
		EM::HttpRequest.new(
			"https://www.canadianbitcoins.com",
			tls: { verify_peer: true }
		).get
	end

	def cad_to_usd
		@redis.get("cad_to_usd").then do |rate|
			next rate.to_f if rate

			EM.promise_defer {
				# OXR gem is not async, so defer to threadpool
				oxr.update_rates
				oxr.get_rate("CAD", "USD")
			}.then do |orate|
				@redis.set("cad_to_usd", orate, ex: 60 * 60).then { orate }
			end
		end
	end
end

M lib/customer.rb => lib/customer.rb +38 -3
@@ 13,7 13,7 @@ class Customer

	def self.for_customer_id(customer_id)
		result = DB.query_defer(<<~SQL, [customer_id])
			SELECT COALESCE(balance,0) AS balance, plan_name
			SELECT COALESCE(balance,0) AS balance, plan_name, expires_at
			FROM customer_plans LEFT JOIN balances USING (customer_id)
			WHERE customer_id=$1 LIMIT 1
		SQL


@@ 22,14 22,37 @@ class Customer
		end
	end

	attr_reader :balance
	attr_reader :customer_id, :balance

	def initialize(customer_id, plan_name: nil, balance: BigDecimal.new(0))
	def initialize(
		customer_id,
		plan_name: nil,
		expires_at: Time.now,
		balance: BigDecimal.new(0)
	)
		@plan = plan_name && Plan.for(plan_name)
		@expires_at = expires_at
		@customer_id = customer_id
		@balance = balance
	end

	def with_plan(plan_name)
		self.class.new(
			@customer_id,
			balance: @balance,
			expires_at: @expires_at,
			plan_name: plan_name
		)
	end

	def plan_name
		@plan.name
	end

	def currency
		@plan.currency
	end

	def merchant_account
		@plan.merchant_account
	end


@@ 41,4 64,16 @@ class Customer
			.find(@customer_id)
			.then(PaymentMethods.method(:for_braintree_customer))
	end

	def active?
		@plan && @expires_at > Time.now
	end

	def registered?
		ibr = IBR.new(:get, CONFIG[:sgx])
		ibr.from = "customer_#{@customer_id}@#{CONFIG[:component][:jid]}"
		IQ_MANAGER.write(ibr).catch { nil }.then do |result|
			result&.respond_to?(:registered?) && result&.registered?
		end
	end
end

A lib/electrum.rb => lib/electrum.rb +80 -0
@@ 0,0 1,80 @@
# frozen_string_literal: true

require "bigdecimal"
require "em_promise"
require "json"
require "net/http"
require "securerandom"

class Electrum
	def initialize(rpc_uri:, rpc_username:, rpc_password:)
		@rpc_uri = URI(rpc_uri)
		@rpc_username = rpc_username
		@rpc_password = rpc_password
	end

	def createnewaddress
		rpc_call(:createnewaddress, {}).then { |r| r["result"] }
	end

	def getaddresshistory(address)
		rpc_call(:getaddresshistory, address: address).then { |r| r["result"] }
	end

	def gettransaction(tx_hash)
		rpc_call(:gettransaction, txid: tx_hash).then { |tx|
			rpc_call(:deserialize, [tx["result"]])
		}.then do |tx|
			Transaction.new(self, tx_hash, tx["result"])
		end
	end

	def get_tx_status(tx_hash)
		rpc_call(:get_tx_status, txid: tx_hash).then { |r| r["result"] }
	end

	class Transaction
		def initialize(electrum, tx_hash, tx)
			@electrum = electrum
			@tx_hash = tx_hash
			@tx = tx
		end

		def confirmations
			@electrum.get_tx_status(@tx_hash).then { |r| r["confirmations"] }
		end

		def amount_for(*addresses)
			BigDecimal.new(
				@tx["outputs"]
					.select { |o| addresses.include?(o["address"]) }
					.map { |o| o["value_sats"] }
					.sum
			) * 0.00000001
		end
	end

protected

	def rpc_call(method, params)
		post_json(
			jsonrpc: "2.0",
			id: SecureRandom.hex,
			method: method.to_s,
			params: params
		).then { |res| JSON.parse(res.response) }
	end

	def post_json(data)
		EM::HttpRequest.new(
			@rpc_uri,
			tls: { verify_peer: true }
		).post(
			head: {
				"Authorization" => [@rpc_username, @rpc_password],
				"Content-Type" => "application/json"
			},
			body: data.to_json
		)
	end
end

M lib/plan.rb => lib/plan.rb +4 -0
@@ 12,6 12,10 @@ class Plan
		@plan = plan
	end

	def name
		@plan[:name]
	end

	def currency
		@plan[:currency]
	end

A lib/registration.rb => lib/registration.rb +169 -0
@@ 0,0 1,169 @@
# frozen_string_literal: true

class Registration
	def self.for(iq, customer, web_register_manager)
		raise "TODO" if customer&.active?

		EMPromise.resolve(customer&.registered?).then do |registered|
			if registered
				Registered.new(iq, result.phone)
			else
				web_register_manager.choose_tel(iq).then do |(riq, tel)|
					Activation.for(riq, customer, tel)
				end
			end
		end
	end

	class Registered
		def initialize(iq, tel)
			@reply = iq.reply
			@reply.status = :completed
			@tel = tel
		end

		def write
			@reply.note_type = :error
			@reply.note_text = <<~NOTE
				You are already registered with JMP number #{@tel}
			NOTE
			BLATHER << @reply
			nil
		end
	end

	class Activation
		def self.for(iq, customer, tel)
			return EMPromise.resolve(new(iq, customer, tel)) if customer

			# Create customer_id
			raise "TODO"
		end

		def initialize(iq, customer, tel)
			@reply = iq.reply
			reply.allowed_actions = [:next]

			@customer = customer
			@tel = tel
		end

		attr_reader :reply, :customer, :tel

		FORM_FIELDS = [
			{
				var: "activation_method",
				type: "list-single",
				label: "Activate using",
				required: true,
				options: [
					{
						value: "bitcoin",
						label: "Bitcoin"
					},
					{
						value: "credit_card",
						label: "Credit Card"
					},
					{
						value: "code",
						label: "Referral or Activation Code"
					}
				]
			},
			{
				var: "plan_name",
				type: "list-single",
				label: "What currency should your account balance be in?",
				required: true,
				options: [
					{
						value: "cad_beta_unlimited-v20210223",
						label: "Canadian Dollars"
					},
					{
						value: "usd_beta_unlimited-v20210223",
						label: "United States Dollars"
					}
				]
			}
		].freeze

		def write
			form = reply.form
			form.type = :form
			form.title = "Activate JMP"
			form.instructions = "Going to activate #{tel} (TODO RATE CTR)"
			form.fields = FORM_FIELDS

			COMMAND_MANAGER.write(reply).then { |iq|
				Payment.for(iq, customer, tel)
			}.then(&:write)
		end
	end

	module Payment
		def self.for(iq, customer, tel)
			case iq.form.field("activation_method")&.value&.to_s
			when "bitcoin"
				Bitcoin.new(iq, customer, tel)
			when "credit_card"
				raise "TODO"
			when "code"
				raise "TODO"
			else
				raise "Invalid activation method"
			end
		end

		class Bitcoin
			def initialize(iq, customer, tel)
				@reply = iq.reply
				reply.note_type = :info
				reply.status = :completed

				plan_name = iq.form.field("plan_name").value.to_s
				@customer = customer.with_plan(plan_name)
				@customer_id = customer.customer_id
				@tel = tel
				@addr = ELECTRUM.createnewaddress
			end

			attr_reader :reply, :customer_id, :tel

			def save
				EMPromise.all([
					REDIS.mset(
						"pending_tel_for-#{customer_id}", tel,
						"pending_plan_for-#{customer_id}", @customer.plan_name
					),
					@addr.then do |addr|
						REDIS.sadd("jmp_customer_btc_addresses-#{customer_id}", addr)
					end
				])
			end

			def note_text(amount, addr)
				<<~NOTE
					Activate your account by sending at least #{'%.6f' % amount} BTC to
					#{addr}

					You will receive a notification when your payment is complete.
				NOTE
			end

			def write
				EMPromise.all([
					@addr,
					save,
					BTC_SELL_PRICES.public_send(@customer.currency.to_s.downcase)
				]).then do |(addr, _, rate)|
					min = CONFIG[:activation_amount] / rate
					reply.note_text = note_text(min, addr)
					BLATHER << reply
					nil
				end
			end
		end
	end
end

A lib/web_register_manager.rb => lib/web_register_manager.rb +35 -0
@@ 0,0 1,35 @@
# frozen_string_literal: true

class WebRegisterManager
	def initialize
		@tel_map = Hash.new { ChooseTel.new }
	end

	def []=(jid, tel)
		@tel_map[jid.to_s] = HaveTel.new(tel)
	end

	def [](jid)
		@tel_map[jid.to_s]
	end

	def choose_tel(iq)
		self[iq&.from&.stripped].choose_tel(iq)
	end

	class HaveTel
		def initialize(tel)
			@tel = tel
		end

		def choose_tel(iq)
			EMPromise.resolve([iq, @tel])
		end
	end

	class ChooseTel
		def choose_tel(_iq)
			raise "TODO"
		end
	end
end

M sgx_jmp.rb => sgx_jmp.rb +28 -0
@@ 8,17 8,24 @@ require "dhall"
require "em-hiredis"
require "em_promise"

require_relative "lib/btc_sell_prices"
require_relative "lib/buy_account_credit_form"
require_relative "lib/customer"
require_relative "lib/electrum"
require_relative "lib/em"
require_relative "lib/existing_registration"
require_relative "lib/payment_methods"
require_relative "lib/registration"
require_relative "lib/transaction"
require_relative "lib/web_register_manager"

CONFIG =
	Dhall::Coder
	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol])
	.load(ARGV[0], transform_keys: ->(k) { k&.to_sym })

ELECTRUM = Electrum.new(**CONFIG[:electrum])

# Braintree is not async, so wrap in EM.defer for now
class AsyncBraintree
	def initialize(environment:, merchant_id:, public_key:, private_key:, **)


@@ 60,13 67,16 @@ Blather::DSL.append_features(self.class)

def panic(e)
	warn "Error raised during event loop: #{e.message}"
	warn e.backtrace if e.respond_to?(:backtrace)
	exit 1
end

EM.error_handler(&method(:panic))

when_ready do
	BLATHER = self
	REDIS = EM::Hiredis.connect
	BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
	DB = PG::EM::Client.new(dbname: "jmp")
	DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
	DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)


@@ 126,6 136,7 @@ end

IQ_MANAGER = SessionManager.new(self, :id)
COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60)
web_register_manager = WebRegisterManager.new

disco_items node: "http://jabber.org/protocol/commands" do |iq|
	reply = iq.reply


@@ 136,11 147,28 @@ disco_items node: "http://jabber.org/protocol/commands" do |iq|
			iq.to,
			"buy-credit",
			"Buy account credit"
		),
		Blather::Stanza::DiscoItems::Item.new(
			iq.to,
			"jabber:iq:register",
			"Register"
		)
	]
	self << reply
end

command :execute?, node: "jabber:iq:register", sessionid: nil do |iq|
	Customer.for_jid(iq.from.stripped).catch {
		nil
	}.then { |customer|
		Registration.for(
			iq,
			customer,
			web_register_manager
		).then(&:write)
	}.catch(&method(:panic))
end

def reply_with_note(iq, text, type: :info)
	reply = iq.reply
	reply.status = :completed

A test/test_btc_sell_prices.rb => test/test_btc_sell_prices.rb +31 -0
@@ 0,0 1,31 @@
# frozen_string_literal: true

require "em-hiredis"
require "test_helper"
require "btc_sell_prices"

class BTCSellPricesTest < Minitest::Test
	def setup
		@redis = Minitest::Mock.new
		@subject = BTCSellPrices.new(@redis, "")
	end

	def test_cad
		stub_request(:get, "https://www.canadianbitcoins.com").to_return(
			body: "<div id='ticker'><table><tbody><tr>" \
			      "<td>Bitcoin</td><td></td><td>$123.00</td>"
		)
		assert_equal BigDecimal.new(123), @subject.cad.sync
	end
	em :test_cad

	def test_usd
		stub_request(:get, "https://www.canadianbitcoins.com").to_return(
			body: "<div id='ticker'><table><tbody><tr>" \
			      "<td>Bitcoin<td></td><td>$123.00</td>"
		)
		@redis.expect(:get, EMPromise.resolve("0.5"), ["cad_to_usd"])
		assert_equal BigDecimal.new(123) / 2, @subject.usd.sync
	end
	em :test_usd
end

A test/test_electrum.rb => test/test_electrum.rb +106 -0
@@ 0,0 1,106 @@
# frozen_string_literal: true

require "test_helper"
require "electrum"

class ElectrumTest < Minitest::Test
	RPC_URI = "http://example.com"

	def setup
		@electrum = Electrum.new(
			rpc_uri: RPC_URI,
			rpc_username: "username",
			rpc_password: "password"
		)
	end

	def stub_rpc(method, params)
		stub_request(:post, RPC_URI).with(
			headers: { "Content-Type" => "application/json" },
			basic_auth: ["username", "password"],
			body: hash_including(
				method: method,
				params: params
			)
		)
	end

	property(:getaddresshistory) { string(:alnum) }
	em :test_getaddresshistory
	def getaddresshistory(address)
		req =
			stub_rpc("getaddresshistory", address: address)
			.to_return(body: { result: "result" }.to_json)
		assert_equal "result", @electrum.getaddresshistory(address).sync
		assert_requested(req)
	end

	property(:get_tx_status) { string(:alnum) }
	em :test_get_tx_status
	def get_tx_status(tx_hash)
		req =
			stub_rpc("get_tx_status", txid: tx_hash)
			.to_return(body: { result: "result" }.to_json)
		assert_equal "result", @electrum.get_tx_status(tx_hash).sync
		assert_requested(req)
	end

	property(:gettransaction) { [string(:alnum), string(:xdigit)] }
	em :test_gettransaction
	def gettransaction(tx_hash, dummy_tx)
		req1 =
			stub_rpc("gettransaction", txid: tx_hash)
			.to_return(body: { result: dummy_tx }.to_json)
		req2 =
			stub_rpc("deserialize", [dummy_tx])
			.to_return(body: { result: { outputs: [] } }.to_json)
		assert_kind_of Electrum::Transaction, @electrum.gettransaction(tx_hash).sync
		assert_requested(req1)
		assert_requested(req2)
	end

	class TransactionTest < Minitest::Test
		def transaction(outputs=[])
			electrum_mock = Minitest::Mock.new("Electrum")
			[
				electrum_mock,
				Electrum::Transaction.new(
					electrum_mock,
					"txhash",
					"outputs" => outputs
				)
			]
		end

		def test_confirmations
			electrum_mock, tx = transaction
			electrum_mock.expect(
				:get_tx_status,
				EMPromise.resolve("confirmations" => 1234),
				["txhash"]
			)
			assert_equal 1234, tx.confirmations.sync
		end
		em :test_confirmations

		def test_amount_for_empty
			_, tx = transaction
			assert_equal 0, tx.amount_for
		end

		def test_amount_for_address_not_present
			_, tx = transaction([{ "address" => "address", "value_sats" => 1 }])
			assert_equal 0, tx.amount_for("other_address")
		end

		def test_amount_for_address_present
			_, tx = transaction([{ "address" => "address", "value_sats" => 1 }])
			assert_equal 0.00000001, tx.amount_for("address")
		end

		def test_amount_for_one_of_address_present
			_, tx = transaction([{ "address" => "address", "value_sats" => 1 }])
			assert_equal 0.00000001, tx.amount_for("boop", "address", "lol")
		end
	end
end

M test/test_helper.rb => test/test_helper.rb +24 -0
@@ 14,6 14,19 @@ require "webmock/minitest"
begin
	require "pry-rescue/minitest"
	require "pry-reload"

	module Minitest
		class Test
			alias old_capture_exceptions capture_exceptions
			def capture_exceptions
				old_capture_exceptions do
					yield
				rescue Minitest::Skip => e
					failures << e
				end
			end
		end
	end
rescue LoadError
	# Just helpers for dev, no big deal if missing
	nil


@@ 24,6 37,7 @@ CONFIG = {
	component: {
		jid: "component"
	},
	activation_amount: 1,
	plans: [
		{
			name: "test_usd",


@@ 45,6 59,16 @@ BLATHER = Class.new {
	def <<(*); end
}.new.freeze

class Matching
	def initialize(&block)
		@block = block
	end

	def ===(other)
		@block.call(other)
	end
end

module Minitest
	class Test
		def self.property(m, &block)

A test/test_registration.rb => test/test_registration.rb +154 -0
@@ 0,0 1,154 @@
# frozen_string_literal: true

require "test_helper"
require "registration"

class RegistrationTest < Minitest::Test
	Customer::IQ_MANAGER = Minitest::Mock.new

	def test_for_activated
		skip "Registration#for activated not implemented yet"
		iq = Blather::Stanza::Iq::Command.new
		Registration.for(iq, Customer.new("test"), Minitest::Mock.new).sync
	end
	em :test_for_activated

	def test_for_not_activated_with_customer_id
		Customer::IQ_MANAGER.expect(
			:write,
			EMPromise.resolve(nil),
			[Blather::Stanza::Iq]
		)
		web_manager = WebRegisterManager.new
		web_manager["test@example.com"] = "+15555550000"
		iq = Blather::Stanza::Iq::Command.new
		iq.from = "test@example.com"
		result = Registration.for(
			iq,
			Customer.new("test"),
			web_manager
		).sync
		assert_kind_of Registration::Activation, result
	end
	em :test_for_not_activated_with_customer_id

	def test_for_not_activated_without_customer_id
		skip "customer_id creation not implemented yet"
		iq = Blather::Stanza::Iq::Command.new
		Registration.for(iq, nil, Minitest::Mock.new).sync
	end
	em :test_for_not_activated_without_customer_id

	class ActivationTest < Minitest::Test
		Registration::Activation::COMMAND_MANAGER = Minitest::Mock.new
		def setup
			iq = Blather::Stanza::Iq::Command.new
			@activation = Registration::Activation.new(iq, "test", "+15555550000")
		end

		def test_write
			result = Minitest::Mock.new
			result.expect(:then, result)
			result.expect(:then, EMPromise.resolve(:test_result))
			Registration::Activation::COMMAND_MANAGER.expect(
				:write,
				result,
				[Blather::Stanza::Iq::Command]
			)
			assert_equal :test_result, @activation.write.sync
		end
		em :test_write
	end

	class PaymentTest < Minitest::Test
		Registration::Payment::Bitcoin::ELECTRUM = Minitest::Mock.new

		def test_for_bitcoin
			Registration::Payment::Bitcoin::ELECTRUM.expect(:createnewaddress, "addr")
			iq = Blather::Stanza::Iq::Command.new
			iq.form.fields = [
				{ var: "activation_method", value: "bitcoin" },
				{ var: "plan_name", value: "test_usd" }
			]
			result = Registration::Payment.for(
				iq,
				Customer.new("test"),
				"+15555550000"
			)
			assert_kind_of Registration::Payment::Bitcoin, result
		end

		def test_for_credit_card
			skip "CreditCard not implemented yet"
			iq = Blather::Stanza::Iq::Command.new
			iq.form.fields = [
				{ var: "activation_method", value: "credit_card" },
				{ var: "plan_name", value: "test_usd" }
			]
			result = Registration::Payment.for(iq, "test", "+15555550000")
			assert_kind_of Registration::Payment::CreditCard, result
		end

		def test_for_code
			skip "Code not implemented yet"
			iq = Blather::Stanza::Iq::Command.new
			iq.form.fields = [
				{ var: "activation_method", value: "code" },
				{ var: "plan_name", value: "test_usd" }
			]
			result = Registration::Payment.for(iq, "test", "+15555550000")
			assert_kind_of Registration::Payment::Code, result
		end

		class BitcoinTest < Minitest::Test
			Registration::Payment::Bitcoin::ELECTRUM = Minitest::Mock.new
			Registration::Payment::Bitcoin::REDIS = Minitest::Mock.new
			Registration::Payment::Bitcoin::BTC_SELL_PRICES = Minitest::Mock.new
			Registration::Payment::Bitcoin::BLATHER = Minitest::Mock.new

			def setup
				Registration::Payment::Bitcoin::ELECTRUM.expect(
					:createnewaddress,
					EMPromise.resolve("testaddr")
				)
				iq = Blather::Stanza::Iq::Command.new
				iq.form.fields = [
					{ var: "plan_name", value: "test_usd" }
				]
				@bitcoin = Registration::Payment::Bitcoin.new(
					iq,
					Customer.new("test"),
					"+15555550000"
				)
			end

			def test_write
				reply_text = <<~NOTE
					Activate your account by sending at least 1.000000 BTC to
					testaddr

					You will receive a notification when your payment is complete.
				NOTE
				Registration::Payment::Bitcoin::BLATHER.expect(
					:<<,
					nil,
					[Matching.new do |reply|
						assert_equal :completed, reply.status
						assert_equal :info, reply.note_type
						assert_equal reply_text, reply.note.content
						true
					end]
				)
				Registration::Payment::Bitcoin::BTC_SELL_PRICES.expect(
					:usd,
					EMPromise.resolve(BigDecimal.new(1))
				)
				@bitcoin.stub(:save, EMPromise.resolve(nil)) do
					@bitcoin.write.sync
				end
				Registration::Payment::Bitcoin::BLATHER.verify
			end
			em :test_write
		end
	end
end

A test/test_web_register_manager.rb => test/test_web_register_manager.rb +32 -0
@@ 0,0 1,32 @@
# frozen_string_literal: true

require "test_helper"
require "web_register_manager"

class WebRegisterManagerTest < Minitest::Test
	def setup
		@manager = WebRegisterManager.new
	end

	def test_set_get
		assert_kind_of WebRegisterManager::ChooseTel, @manager["jid@example.com"]
		@manager["jid@example.com"] = "+15555550000"
		assert_kind_of WebRegisterManager::HaveTel, @manager["jid@example.com"]
	end

	def test_choose_tel_have_tel
		@manager["jid@example.com"] = "+15555550000"
		iq = Blather::Stanza::Iq.new
		iq.from = "jid@example.com"
		assert_equal [iq, "+15555550000"], @manager.choose_tel(iq).sync
	end
	em :test_choose_tel_have_tel

	def test_choose_tel_not_have_tel
		skip "ChooseTel not implemented yet"
		iq = Blather::Stanza::Iq.new
		iq.from = "jid@example.com"
		@manager.choose_tel(iq).sync
	end
	em :test_choose_tel_not_have_tel
end