~singpolyma/sgx-jmp

4be555de103e992ee7e03feb48b45c1eca917c45 — Stephen Paul Weber 1 year, 27 days ago e5730b6
Split logic out into testable objects

Rubocop clean. Good test coverage of helper objects.
M .gitignore => .gitignore +2 -1
@@ 1,4 1,5 @@
Gemfile.lock
.bundle
.gems
config.dhall
\ No newline at end of file
*.dhall
coverage/

M .rubocop.yml => .rubocop.yml +9 -0
@@ 3,6 3,12 @@ AllCops:

Metrics/LineLength:
  Max: 80
  Exclude:
    - Gemfile

Metrics/MethodLength:
  Exclude:
    - test/*

Style/Tab:
  Enabled: false


@@ 13,6 19,9 @@ Style/IndentationWidth:
Style/StringLiterals:
  EnforcedStyle: double_quotes

Style/NumericLiterals:
  Enabled: false

Style/SymbolArray:
  EnforcedStyle: brackets


M Gemfile => Gemfile +11 -2
@@ 2,15 2,24 @@

source "https://rubygems.org"

gem "blather"
gem "blather", git: "https://github.com/singpolyma/blather.git", branch: "ergonomics"
gem "braintree"
gem "dhall"
gem "em-hiredis"
gem "em-pg-client", git: "https://github.com/royaltm/ruby-em-pg-client"
gem "em_promise.rb"
gem "eventmachine"
gem "time-hash"

group(:development) do
	gem "pry-reload"
	gem "pry-remote-em"
	gem "pry-rescue"
	gem "pry-stack_explorer"
end

group(:test) do
	gem "minitest"
	gem "rantly"
	gem "simplecov", require: false
	gem "webmock"
end

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

require_relative "./xep0122_field"

class BuyAccountCreditForm
	def initialize(customer)
		@customer = customer
	end

	AMOUNT_FIELD =
		XEP0122Field.new(
			"xs:decimal",
			range: (0..1000),
			var: "amount",
			label: "Amount of credit to buy",
			required: true
		).field

	def balance
		{
			type: "fixed",
			value: "Current balance: $#{'%.2f' % @customer.balance}"
		}
	end

	def add_to_form(form)
		@customer.payment_methods.then do |payment_methods|
			form.type = :form
			form.title = "Buy Account Credit"
			form.fields = [
				balance,
				payment_methods.to_list_single,
				AMOUNT_FIELD
			]
		end
	end
end

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

require_relative "./payment_methods"
require_relative "./plan"

class Customer
	def self.for_jid(jid)
		REDIS.get("jmp_customer_id-#{jid}").then do |customer_id|
			raise "No customer id" unless customer_id
			for_customer_id(customer_id)
		end
	end

	def self.for_customer_id(customer_id)
		result = DB.query_defer(<<~SQL, [customer_id])
			SELECT COALESCE(balance,0) AS balance, plan_name
			FROM customer_plans LEFT JOIN balances USING (customer_id)
			WHERE customer_id=$1 LIMIT 1
		SQL
		result.then do |rows|
			new(customer_id, **rows.first&.transform_keys(&:to_sym) || {})
		end
	end

	attr_reader :balance

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

	def merchant_account
		@plan.merchant_account
	end

	def payment_methods
		@payment_methods ||=
			BRAINTREE
			.customer
			.find(@customer_id)
			.then(PaymentMethods.method(:for_braintree_customer))
	end
end

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

require "em_promise"

module EM
	def self.promise_defer(klass: EMPromise, &block)
		promise = klass.new
		EventMachine.defer(
			block,
			promise.method(:fulfill),
			promise.method(:reject)
		)
		promise
	end
end

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

require "blather"

class IBR < Blather::Stanza::Iq::Query
	register :ibr, nil, "jabber:iq:register"

	def registered=(reg)
		query.at_xpath("./ns:registered", ns: self.class.registered_ns)&.remove
		node = Nokogiri::XML::Node.new("registered", document)
		node.default_namespace = self.class.registered_ns
		query << node if reg
	end

	def registered?
		!!query.at_xpath("./ns:registered", ns: self.class.registered_ns)
	end

	[
		"instructions",
		"username",
		"nick",
		"password",
		"name",
		"first",
		"last",
		"email",
		"address",
		"city",
		"state",
		"zip",
		"phone",
		"url",
		"date"
	].each do |tag|
		define_method("#{tag}=") do |v|
			query.at_xpath("./ns:#{tag}", ns: self.class.registered_ns)&.remove
			node = Nokogiri::XML::Node.new(tag, document)
			node.default_namespace = self.class.registered_ns
			node.content = v
			query << node
		end

		define_method(tag) do
			query.at_xpath("./ns:#{tag}", ns: self.class.registered_ns)&.content
		end
	end
end

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

class PaymentMethods
	def self.for_braintree_customer(braintree_customer)
		methods = braintree_customer.payment_methods
		if methods.empty?
			Empty.new
		else
			new(methods)
		end
	end

	def initialize(methods)
		@methods = methods
	end

	def fetch(idx)
		@methods.fetch(idx)
	end

	def default_payment_method
		@methods.index(&:default?).to_s
	end

	def to_options
		@methods.map.with_index do |method, idx|
			{
				value: idx.to_s,
				label: "#{method.card_type} #{method.last_4}"
			}
		end
	end

	def to_list_single(**kwargs)
		{
			var: "payment_method",
			type: "list-single",
			label: "Credit card to pay with",
			required: true,
			value: default_payment_method,
			options: to_options
		}.merge(kwargs)
	end

	class Empty
		def to_list_single(*)
			raise "No payment methods available"
		end
	end
end

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

class Plan
	def self.for(plan_name)
		plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
		raise "No plan by that name" unless plan

		new(plan)
	end

	def initialize(plan)
		@plan = plan
	end

	def currency
		@plan[:currency]
	end

	def merchant_account
		CONFIG[:braintree][:merchant_accounts].fetch(currency) do
			raise "No merchant account for this currency"
		end
	end
end

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

class Transaction
	def self.sale(merchant_account, payment_method, amount)
		BRAINTREE.transaction.sale(
			amount: amount,
			payment_method_token: payment_method.token,
			merchant_account_id: merchant_account,
			options: { submit_for_settlement: true }
		).then do |response|
			raise response.message unless response.success?
			new(response.transaction)
		end
	end

	attr_reader :amount

	def initialize(braintree_transaction)
		@customer_id = braintree_transaction.customer_details.id
		@transaction_id = braintree_transaction.id
		@created_at = braintree_transaction.created_at
		@amount = braintree_transaction.amount
	end

	def insert
		params = [@customer_id, @transaction_id, @created_at, @amount]
		DB.exec_defer(<<~SQL, params)
			INSERT INTO transactions
				(customer_id, transaction_id, created_at, amount)
			VALUES
				($1, $2, $3, $4)
		SQL
	end
end

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

require "blather"
require "nokogiri"

class XEP0122Field
	attr_reader :field

	def initialize(type, range: nil, **field)
		@type = type
		@range = range
		@field = Blather::Stanza::X::Field.new(**field)
		@field.add_child(validate)
	end

protected

	def validate
		validate = Nokogiri::XML::Node.new("validate", field.document)
		validate.default_namespace = "http://jabber.org/protocol/xdata-validate"
		validate["datatype"] = @type
		validate.add_child(validation)
		validate
	end

	def validation
		range_node || Nokogiri::XML::Node.new(
			"basic",
			field.document
		).tap do |basic|
			basic.default_namespace = "http://jabber.org/protocol/xdata-validate"
		end
	end

	def range_node
		return unless @range

		Nokogiri::XML::Node.new("range", field.document).tap do |range|
			range.default_namespace = "http://jabber.org/protocol/xdata-validate"
			range["min"] = @range.min.to_s if @range.min
			range["max"] = @range.max.to_s if @range.max
		end
	end
end

M sgx_jmp.rb => sgx_jmp.rb +65 -297
@@ 7,7 7,12 @@ require "braintree"
require "dhall"
require "em-hiredis"
require "em_promise"
require "time-hash"

require_relative "lib/buy_account_credit_form"
require_relative "lib/customer"
require_relative "lib/em"
require_relative "lib/payment_methods"
require_relative "lib/transaction"

CONFIG =
	Dhall::Coder


@@ 32,13 37,9 @@ class AsyncBraintree
	def method_missing(m, *args)
		return super unless respond_to_missing?(m, *args)

		promise = PromiseChain.new
		EventMachine.defer(
			-> { @gateway.public_send(m, *args) },
			promise.method(:fulfill),
			promise.method(:reject)
		)
		promise
		EM.promise_defer(klass: PromiseChain) do
			@gateway.public_send(m, *args)
		end
	end

	class PromiseChain < EMPromise


@@ 55,100 56,6 @@ end

BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])

def node(name, parent, ns: nil)
	Niceogiri::XML::Node.new(
		name,
		parent.document,
		ns || parent.class.registered_ns
	)
end

def escape_jid(localpart)
	# TODO: proper XEP-0106 Sec 4.3, ie. pre-escaped
	localpart
		.to_s
		.gsub("\\", "\\\\5c")
		.gsub(" ", "\\\\20")
		.gsub("\"", "\\\\22")
		.gsub("&", "\\\\26")
		.gsub("'", "\\\\27")
		.gsub("/", "\\\\2f")
		.gsub(":", "\\\\3a")
		.gsub("<", "\\\\3c")
		.gsub(">", "\\\\3e")
		.gsub("@", "\\\\40")
end

def unescape_jid(localpart)
	localpart
		.to_s
		.gsub("\\20", " ")
		.gsub("\\22", "\"")
		.gsub("\\26", "&")
		.gsub("\\27", "'")
		.gsub("\\2f", "/")
		.gsub("\\3a", ":")
		.gsub("\\3c", "<")
		.gsub("\\3e", ">")
		.gsub("\\40", "@")
		.gsub("\\5c", "\\")
end

def proxy_jid(jid)
	Blather::JID.new(
		escape_jid(jid.stripped),
		CONFIG[:component][:jid],
		jid.resource
	)
end

def unproxy_jid(jid)
	parsed = Blather::JID.new(unescape_jid(jid.node))
	Blather::JID.new(parsed.node, parsed.domain, jid.resource)
end

class IBR < Blather::Stanza::Iq::Query
	register :ibr, nil, "jabber:iq:register"

	def registered=(reg)
		query.at_xpath("./ns:registered", ns: self.class.registered_ns)&.remove
		query << node("registered", self) if reg
	end

	def registered?
		!!query.at_xpath("./ns:registered", ns: self.class.registered_ns)
	end

	[
		"instructions",
		"username",
		"nick",
		"password",
		"name",
		"first",
		"last",
		"last",
		"email",
		"address",
		"city",
		"state",
		"zip",
		"phone",
		"url",
		"date"
	].each do |tag|
		define_method("#{tag}=") do |v|
			query.at_xpath("./ns:#{tag}", ns: self.class.registered_ns)&.remove
			query << (i = node(tag, self))
			i.content = v
		end

		define_method(tag) do
			query.at_xpath("./ns:#{tag}", ns: self.class.registered_ns)&.content
		end
	end
end

Blather::DSL.append_features(self.class)

def panic(e)


@@ 186,108 93,40 @@ message :error? do |m|
	puts "MESSAGE ERROR: #{m.inspect}"
end

ibr :get? do |iq|
	fwd = iq.dup
	fwd.from = proxy_jid(iq.from)
	fwd.to = Blather::JID.new(nil, CONFIG[:sgx], iq.to.resource)
	fwd.id = "JMPGET%#{iq.id}"
	self << fwd
end

ibr :result? do |iq|
	if iq.id.start_with?("JMPGET")
		reply = iq.reply
		reply.instructions =
			"Please enter the phone number you wish to register with JMP.chat"
		reply.registered = iq.registered?
		reply.phone = iq.phone
	else
		reply = iq.dup
class SessionManager
	def initialize(blather, id_msg, timeout: 5)
		@blather = blather
		@sessions = {}
		@id_msg = id_msg
		@timeout = timeout
	end

	reply.id = iq.id.sub(/JMP[GS]ET%/, "")
	reply.from = Blather::JID.new(
		nil,
		CONFIG[:component][:jid],
		iq.from.resource
	)
	reply.to = unproxy_jid(iq.to)
	self << reply
end

ibr :error? do |iq|
	reply = iq.dup
	reply.id = iq.id.sub(/JMP[GS]ET%/, "")
	reply.from = Blather::JID.new(
		nil,
		CONFIG[:component][:jid],
		iq.from.resource
	)
	reply.to = unproxy_jid(iq.to)
	self << reply
end

ibr :set? do |iq|
	fwd = iq.dup
	CONFIG[:creds].each do |k, v|
		fwd.public_send("#{k}=", v)
	end
	fwd.from = proxy_jid(iq.from)
	fwd.to = Blather::JID.new(nil, CONFIG[:sgx], iq.to.resource)
	fwd.id = "JMPSET%#{iq.id}"
	self << fwd
end

@command_sessions = TimeHash.new
def command_reply_and_promise(reply)
	promise = EMPromise.new
	@command_sessions.put(reply.sessionid, promise, 60 * 60)
	self << reply
	promise
end

def command_reply_and_done(reply)
	@command_sessions.delete(reply.sessionid)
	self << reply
end

class XEP0122Field
	attr_reader :field

	def initialize(type, range: nil, **field)
		@type = type
		@range = range
		@field = Blather::Stanza::X::Field.new(**field)
		@field.add_child(validate)
	end

protected

	def validate
		validate = Nokogiri::XML::Node.new("validate", field.document)
		validate["xmlns"] = "http://jabber.org/protocol/xdata-validate"
		validate["datatype"] = @type
		validate.add_child(validation)
		validate
	end

	def validation
		range_node || begin
			validation = Nokogiri::XML::Node.new("basic", field.document)
			validation["xmlns"] = "http://jabber.org/protocol/xdata-validate"
	def promise_for(stanza)
		id = "#{stanza.to.stripped}/#{stanza.public_send(@id_msg)}"
		@sessions.fetch(id) do
			@sessions[id] = EMPromise.new
			EM.add_timer(@timeout) do
				@sessions.delete(id)&.reject(:timeout)
			end
			@sessions[id]
		end
	end

	def range_node
		return unless @range
	def write(stanza)
		promise = promise_for(stanza)
		@blather << stanza
		promise
	end

		validation = Nokogiri::XML::Node.new("range", field.document)
		validation["xmlns"] = "http://jabber.org/protocol/xdata-validate"
		validation["min"] = @range.min.to_s if @range.min
		validation["max"] = @range.max.to_s if @range.max
	def fulfill(stanza)
		id = "#{stanza.from.stripped}/#{stanza.public_send(@id_msg)}"
		@sessions.delete(id)&.fulfill(stanza)
	end
end

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

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


@@ 302,123 141,52 @@ disco_items node: "http://jabber.org/protocol/commands" do |iq|
	self << reply
end

command :execute?, node: "buy-credit", sessionid: nil do |iq|
def reply_with_note(iq, text, type: :info)
	reply = iq.reply
	reply.new_sessionid!
	reply.node = iq.node
	reply.status = :executing
	reply.allowed_actions = [:complete]

	REDIS.get("jmp_customer_id-#{iq.from.stripped}").then	{ |customer_id|
		raise "No customer id" unless customer_id
	reply.status = :completed
	reply.note_type = type
	reply.note_text = text

		EMPromise.all([
			DB.query_defer(
				"SELECT COALESCE(balance,0) AS balance, plan_name FROM " \
				"balances LEFT JOIN customer_plans USING (customer_id) " \
				"WHERE customer_id=$1 LIMIT 1",
				[customer_id]
			).then do |rows|
				rows.first || { "balance" => BigDecimal.new(0) }
			end,
			BRAINTREE.customer.find(customer_id).payment_methods
		])
	}.then { |(row, payment_methods)|
		raise "No payment methods available" if payment_methods.empty?

		plan = CONFIG[:plans].find { |p| p[:name] == row["plan_name"] }
		raise "No plan for this customer" unless plan
		merchant_account = CONFIG[:braintree][:merchant_accounts][plan[:currency]]
		raise "No merchant account for this currency" unless merchant_account

		default_payment_method = payment_methods.index(&:default?)
	self << reply
end

		form = reply.form
		form.type = :form
		form.title = "Buy Account Credit"
		form.fields = [
			{
				type: "fixed",
				value: "Current balance: $#{'%.2f' % row['balance']}"
			},
			if payment_methods.length > 1
				{
					var: "payment_method",
					type: "list-single",
					label: "Credit card to pay with",
					value: default_payment_method.to_s,
					required: true,
					options: payment_methods.map.with_index do |method, idx|
						{
							value: idx.to_s,
							label: "#{method.card_type} #{method.last_4}"
						}
					end
				}
			end,
			XEP0122Field.new(
				"xs:decimal",
				range: (0..1000),
				var: "amount",
				label: "Amount of credit to buy",
				required: true
			).field
		].compact
command :execute?, node: "buy-credit", sessionid: nil do |iq|
	reply = iq.reply
	reply.allowed_actions = [:complete]

	Customer.for_jid(iq.from.stripped).then { |customer|
		BuyAccountCreditForm.new(customer).add_to_form(reply.form).then { customer }
	}.then { |customer|
		EMPromise.all([
			payment_methods,
			merchant_account,
			command_reply_and_promise(reply)
			customer.payment_methods,
			customer.merchant_account,
			COMMAND_MANAGER.write(reply)
		])
	}.then { |(payment_methods, merchant_account, iq2)|
		iq = iq2 # This allows the catch to use it also
		payment_method = payment_methods.fetch(
			iq.form.field("payment_method")&.value.to_i
		)
		BRAINTREE.transaction.sale(
			amount: iq.form.field("amount").value.to_s,
			payment_method_token: payment_method.token,
			merchant_account_id: merchant_account,
			options: { submit_for_settlement: true }
		)
	}.then { |braintree_response|
		raise braintree_response.message unless braintree_response.success?
		transaction = braintree_response.transaction

		DB.exec_defer(
			"INSERT INTO transactions " \
			"(customer_id, transaction_id, created_at, amount) " \
			"VALUES($1, $2, $3, $4)",
			[
				transaction.customer_details.id,
				transaction.id,
				transaction.created_at,
				transaction.amount
			]
		).then { transaction.amount }
		amount = iq.form.field("amount").value.to_s
		Transaction.sale(merchant_account, payment_method, amount)
	}.then { |transaction|
		transaction.insert.then { transaction.amount }
	}.then { |amount|
		reply2 = iq.reply
		reply2.command[:sessionid] = iq.sessionid
		reply2.node = iq.node
		reply2.status = :completed
		note = reply2.note
		note[:type] = :info
		note.content = "$#{'%.2f' % amount} added to your account balance."

		command_reply_and_done(reply2)
		reply_with_note(iq, "$#{'%.2f' % amount} added to your account balance.")
	}.catch { |e|
		reply2 = iq.reply
		reply2.command[:sessionid] = iq.sessionid
		reply2.node = iq.node
		reply2.status = :completed
		note = reply2.note
		note[:type] = :error
		note.content = "Failed to buy credit, system said: #{e.message}"

		command_reply_and_done(reply2)
		text = "Failed to buy credit, system said: #{e.message}"
		reply_with_note(iq, text, type: :error)
	}.catch(&method(:panic))
end

command sessionid: /./ do |iq|
	@command_sessions[iq.sessionid]&.fulfill(iq)
	COMMAND_MANAGER.fulfill(iq)
end

iq :result? do |iq|
	IQ_MANAGER.fulfill(iq)
end

iq :error? do |iq|
	IQ_MANAGER.fulfill(iq)
end

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

require "test_helper"
require "buy_account_credit_form"
require "customer"

class BuyAccountCreditFormTest < Minitest::Test
	def setup
		@customer = Customer.new(
			1,
			plan_name: "test_usd",
			balance: BigDecimal.new("12.1234")
		)
		@customer.instance_variable_set(
			:@payment_methods,
			EMPromise.resolve(PaymentMethods.new([
				OpenStruct.new(card_type: "Test", last_4: "1234")
			]))
		)
		@form = BuyAccountCreditForm.new(@customer)
	end

	def test_balance
		assert_equal(
			{ type: "fixed", value: "Current balance: $12.12" },
			@form.balance
		)
	end

	def test_add_to_form
		iq_form = Blather::Stanza::X.new
		@form.add_to_form(iq_form).sync
		assert_equal :form, iq_form.type
		assert_equal "Buy Account Credit", iq_form.title
		assert_equal(
			[
				Blather::Stanza::X::Field.new(
					type: "fixed",
					value: "Current balance: $12.12"
				),
				Blather::Stanza::X::Field.new(
					type: "list-single",
					var: "payment_method",
					label: "Credit card to pay with",
					value: "",
					required: true,
					options: [{ label: "Test 1234", value: "0" }]
				),
				BuyAccountCreditForm::AMOUNT_FIELD
			],
			iq_form.fields
		)
	end
	em :test_add_to_form
end

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

require "test_helper"
require "customer"

Customer::REDIS = Minitest::Mock.new
Customer::DB = Minitest::Mock.new

class CustomerTest < Minitest::Test
	def test_for_jid
		Customer::REDIS.expect(
			:get,
			EMPromise.resolve(1),
			["jmp_customer_id-test@example.com"]
		)
		Customer::DB.expect(
			:query_defer,
			EMPromise.resolve([{ balance: 1234, plan_name: "test_usd" }]),
			[String, [1]]
		)
		customer = Customer.for_jid("test@example.com").sync
		assert_kind_of Customer, customer
		assert_equal 1234, customer.balance
		assert_equal "merchant_usd", customer.merchant_account
	end
	em :test_for_jid

	def test_for_jid_not_found
		Customer::REDIS.expect(
			:get,
			EMPromise.resolve(nil),
			["jmp_customer_id-test2@example.com"]
		)
		assert_raises do
			Customer.for_jid("test2@example.com").sync
		end
	end
	em :test_for_jid_not_found

	def test_for_customer_id_not_found
		Customer::DB.expect(
			:query_defer,
			EMPromise.resolve([]),
			[String, [7357]]
		)
		customer = Customer.for_customer_id(7357).sync
		assert_equal BigDecimal.new(0), customer.balance
	end
	em :test_for_customer_id_not_found
end

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

require "simplecov"
SimpleCov.start do
	add_filter "/test/"
	enable_coverage :branch
end

require "em_promise"
require "fiber"
require "minitest/autorun"
require "rantly/minitest_extensions"
require "webmock/minitest"
begin
	require "pry-rescue/minitest"
	require "pry-reload"
rescue LoadError
	# Just helpers for dev, no big deal if missing
	nil
end

CONFIG = {
	sgx: "sgx",
	component: {
		jid: "component"
	},
	plans: [
		{
			name: "test_usd",
			currency: :USD
		},
		{
			name: "test_bad_currency",
			currency: :BAD
		}
	],
	braintree: {
		merchant_accounts: {
			USD: "merchant_usd"
		}
	}
}.freeze

BLATHER = Class.new {
	def <<(*); end
}.new.freeze

module Minitest
	class Test
		def self.property(m, &block)
			define_method("test_#{m}") do
				property_of(&block).check { |args| send(m, *args) }
			end
		end

		def self.em(m)
			alias_method "raw_#{m}", m
			define_method(m) do
				EM.run do
					Fiber.new {
						begin
							send("raw_#{m}")
						ensure
							EM.stop
						end
					}.resume
				end
			end
		end
	end
end

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

require "test_helper"
require "ibr"

class IBRTest < Minitest::Test
	property(:registered) { boolean }
	def registered(val)
		ibr = IBR.new
		ibr.registered = val
		assert_equal val, ibr.registered?
	end

	{
		instructions: :string,
		username: :string,
		nick: :string,
		password: :string,
		name: :string,
		first: :string,
		last: :string,
		email: :string,
		address: :string,
		city: :string,
		state: :string,
		zip: :string,
		phone: [:string, :digit],
		url: :string,
		date: ->(*) { Time.at(range(0, 4294967295)).iso8601 }
	}.each do |prop, type|
		property("prop_#{prop}") { call(type) }
		define_method("prop_#{prop}") do |val|
			ibr = IBR.new
			ibr.public_send("#{prop}=", val)
			assert_equal val, ibr.public_send(prop)
		end
	end
end

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

require "test_helper"
require "payment_methods"

class PaymentMethodsTest < Minitest::Test
	def test_for_braintree_customer
		braintree_customer = Minitest::Mock.new
		braintree_customer.expect(:payment_methods, [
			OpenStruct.new(card_type: "Test", last_4: "1234")
		])
		methods = PaymentMethods.for_braintree_customer(braintree_customer)
		assert_kind_of PaymentMethods, methods
	end

	def test_for_braintree_customer_no_methods
		braintree_customer = Minitest::Mock.new
		braintree_customer.expect(:payment_methods, [])
		methods = PaymentMethods.for_braintree_customer(braintree_customer)
		assert_raises do
			methods.to_list_single
		end
	end

	def test_default_payment_method
		methods = PaymentMethods.new([
			OpenStruct.new(card_type: "Test", last_4: "1234"),
			OpenStruct.new(card_type: "Test", last_4: "1234", default?: true)
		])
		assert_equal "1", methods.default_payment_method
	end

	def test_to_options
		methods = PaymentMethods.new([
			OpenStruct.new(card_type: "Test", last_4: "1234")
		])
		assert_equal(
			[
				{ value: "0", label: "Test 1234" }
			],
			methods.to_options
		)
	end

	def test_to_list_single
		methods = PaymentMethods.new([
			OpenStruct.new(card_type: "Test", last_4: "1234")
		])
		assert_equal(
			{
				var: "payment_method",
				type: "list-single",
				label: "Credit card to pay with",
				required: true,
				value: "",
				options: [
					{ value: "0", label: "Test 1234" }
				]
			},
			methods.to_list_single
		)
	end

	class EmptyTest < Minitest::Test
		def test_to_list_single
			assert_raises do
				PaymentMethods::Empty.new.to_list_single
			end
		end
	end
end

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

require "test_helper"
require "plan"

class PlanTest < Minitest::Test
	def test_for_non_existing
		assert_raises do
			Plan.for("non_existing")
		end
	end

	def test_currency
		assert_equal :USD, Plan.for("test_usd").currency
	end

	def test_merchant_account
		assert_equal "merchant_usd", Plan.for("test_usd").merchant_account
	end

	def test_merchant_account_bad_currency
		assert_raises do
			Plan.for("test_bad_currency").merchant_account
		end
	end
end

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

require "test_helper"
require "transaction"

Transaction::DB = Minitest::Mock.new
Transaction::BRAINTREE = Minitest::Mock.new

class TransactionTest < Minitest::Test
	FAKE_BRAINTREE_TRANSACTION =
		OpenStruct.new(
			customer_details: OpenStruct.new(id: "customer"),
			id: "transaction",
			created_at: Time.at(0),
			amount: 123
		)

	def test_sale_fails
		braintree_transaction = Minitest::Mock.new
		Transaction::BRAINTREE.expect(:transaction, braintree_transaction)
		braintree_transaction.expect(
			:sale,
			EMPromise.resolve(
				OpenStruct.new(success?: false)
			),
			[Hash]
		)
		assert_raises do
			Transaction.sale(
				"merchant_usd",
				OpenStruct.new(token: "token"),
				123
			).sync
		end
	end
	em :test_sale_fails

	def test_sale
		braintree_transaction = Minitest::Mock.new
		Transaction::BRAINTREE.expect(:transaction, braintree_transaction)
		braintree_transaction.expect(
			:sale,
			EMPromise.resolve(
				OpenStruct.new(
					success?: true,
					transaction: FAKE_BRAINTREE_TRANSACTION
				)
			),
			[{
				amount: 123,
				payment_method_token: "token",
				merchant_account_id: "merchant_usd",
				options: { submit_for_settlement: true }
			}]
		)
		result = Transaction.sale(
			"merchant_usd",
			OpenStruct.new(token: "token"),
			123
		).sync
		assert_kind_of Transaction, result
	end
	em :test_sale

	def test_insert
		Transaction::DB.expect(
			:exec_defer,
			EMPromise.resolve(nil),
			[
				String,
				["customer", "transaction", Time.at(0), 123]
			]
		)
		Transaction.new(FAKE_BRAINTREE_TRANSACTION).insert.sync
	end
	em :test_insert
end

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

require "test_helper"
require "xep0122_field"

class XEP0122FieldTest < Minitest::Test
	def test_field
		field = XEP0122Field.new(
			"xs:decimal",
			range: (0..3),
			var: "v",
			label: "l",
			type: "text-single"
		).field

		example = Nokogiri::XML::Builder.new do |xml|
			xml.field(
				xmlns: "jabber:x:data",
				var: "v",
				type: "text-single",
				label: "l"
			) do
				xml.validate(
					xmlns: "http://jabber.org/protocol/xdata-validate",
					datatype: "xs:decimal"
				) do
					xml.range(min: 0, max: 3)
				end
			end
		end

		assert_equal example.doc.root.to_xml, field.to_xml
	end

	def test_field_no_range
		field = XEP0122Field.new(
			"xs:decimal",
			var: "v",
			label: "l",
			type: "text-single"
		).field

		example = Nokogiri::XML::Builder.new do |xml|
			xml.field(
				xmlns: "jabber:x:data",
				var: "v",
				type: "text-single",
				label: "l"
			) do
				xml.validate(
					xmlns: "http://jabber.org/protocol/xdata-validate",
					datatype: "xs:decimal"
				) do
					xml.basic
				end
			end
		end

		assert_equal example.doc.root.to_xml, field.to_xml
	end
end