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