Merge branch 'register-command-first-pass'

Stephen Paul Weber created

* 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

Change summary

.builds/debian-stable.yml         |  22 ++++
.rubocop.yml                      |   4 
Gemfile                           |   2 
Rakefile                          |  22 ++++
lib/btc_sell_prices.rb            |  57 +++++++++++
lib/customer.rb                   |  41 +++++++
lib/electrum.rb                   |  80 +++++++++++++++
lib/plan.rb                       |   4 
lib/registration.rb               | 169 +++++++++++++++++++++++++++++++++
lib/web_register_manager.rb       |  35 ++++++
sgx_jmp.rb                        |  28 +++++
test/test_btc_sell_prices.rb      |  31 ++++++
test/test_electrum.rb             | 106 ++++++++++++++++++++
test/test_helper.rb               |  24 ++++
test/test_registration.rb         | 154 ++++++++++++++++++++++++++++++
test/test_web_register_manager.rb |  32 ++++++
16 files changed, 808 insertions(+), 3 deletions(-)

Detailed changes

.builds/debian-stable.yml 🔗

@@ -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

.rubocop.yml 🔗

@@ -10,6 +10,10 @@ Metrics/MethodLength:
   Exclude:
     - test/*
 
+Metrics/AbcSize:
+  Exclude:
+    - test/*
+
 Style/Tab:
   Enabled: false
 

Gemfile 🔗

@@ -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"

Rakefile 🔗

@@ -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

lib/btc_sell_prices.rb 🔗

@@ -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

lib/customer.rb 🔗

@@ -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

lib/electrum.rb 🔗

@@ -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

lib/plan.rb 🔗

@@ -12,6 +12,10 @@ class Plan
 		@plan = plan
 	end
 
+	def name
+		@plan[:name]
+	end
+
 	def currency
 		@plan[:currency]
 	end

lib/registration.rb 🔗

@@ -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

lib/web_register_manager.rb 🔗

@@ -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

sgx_jmp.rb 🔗

@@ -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

test/test_btc_sell_prices.rb 🔗

@@ -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

test/test_electrum.rb 🔗

@@ -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

test/test_helper.rb 🔗

@@ -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)

test/test_registration.rb 🔗

@@ -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

test/test_web_register_manager.rb 🔗

@@ -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