Split logic out into testable objects

Stephen Paul Weber created

Rubocop clean. Good test coverage of helper objects.

Change summary

.gitignore                           |   3 
.rubocop.yml                         |   9 
Gemfile                              |  13 
lib/buy_account_credit_form.rb       |  37 +++
lib/customer.rb                      |  44 +++
lib/em.rb                            |  15 +
lib/ibr.rb                           |  48 +++
lib/payment_methods.rb               |  50 ++++
lib/plan.rb                          |  24 +
lib/transaction.rb                   |  34 ++
lib/xep0122_field.rb                 |  44 +++
sgx_jmp.rb                           | 362 +++++------------------------
test/test_buy_account_credit_form.rb |  55 ++++
test/test_customer.rb                |  50 ++++
test/test_helper.rb                  |  71 +++++
test/test_ibr.rb                     |  38 +++
test/test_payment_methods.rb         |  71 +++++
test/test_plan.rb                    |  26 ++
test/test_transaction.rb             |  77 ++++++
test/test_xep0122_field.rb           |  61 +++++
20 files changed, 832 insertions(+), 300 deletions(-)

Detailed changes

.gitignore 🔗

@@ -1,4 +1,5 @@
 Gemfile.lock
 .bundle
 .gems
-config.dhall
+*.dhall
+coverage/

.rubocop.yml 🔗

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

Gemfile 🔗

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

lib/buy_account_credit_form.rb 🔗

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

lib/customer.rb 🔗

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

lib/em.rb 🔗

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

lib/ibr.rb 🔗

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

lib/payment_methods.rb 🔗

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

lib/plan.rb 🔗

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

lib/transaction.rb 🔗

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

lib/xep0122_field.rb 🔗

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

sgx_jmp.rb 🔗

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

test/test_buy_account_credit_form.rb 🔗

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

test/test_customer.rb 🔗

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

test/test_helper.rb 🔗

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

test/test_ibr.rb 🔗

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

test/test_payment_methods.rb 🔗

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

test/test_plan.rb 🔗

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

test/test_transaction.rb 🔗

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

test/test_xep0122_field.rb 🔗

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