diff --git a/.gitignore b/.gitignore index ebb559d48e08b5669fb468b0371f3f9619c653a2..ab5eb8ed857dc3a0a16011309fa22b3c20f31ac5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ Gemfile.lock .bundle .gems -config.dhall \ No newline at end of file +*.dhall +coverage/ diff --git a/.rubocop.yml b/.rubocop.yml index fc5aa5b5097d38f2a8d977f188422be9ee056a50..8ed9f0e0aeb98b105abeca61f2809fa05c80f91a 100644 --- a/.rubocop.yml +++ b/.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 diff --git a/Gemfile b/Gemfile index 91d946ad2a090009b7df94cc5ebb98dffe91c29b..e777509a0337ce474eb12f431c1dde4ca2e0f6a7 100644 --- a/Gemfile +++ b/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 diff --git a/lib/buy_account_credit_form.rb b/lib/buy_account_credit_form.rb new file mode 100644 index 0000000000000000000000000000000000000000..110bb671eca0d3fa734851b8fcd85b4065200148 --- /dev/null +++ b/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 diff --git a/lib/customer.rb b/lib/customer.rb new file mode 100644 index 0000000000000000000000000000000000000000..5f1844abfb2edca4984ac74c0c2e5d313bfc5b9b --- /dev/null +++ b/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 diff --git a/lib/em.rb b/lib/em.rb new file mode 100644 index 0000000000000000000000000000000000000000..911564134b8079f334337f717eae032e6dfcfacf --- /dev/null +++ b/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 diff --git a/lib/ibr.rb b/lib/ibr.rb new file mode 100644 index 0000000000000000000000000000000000000000..29faf6ab7700b26f09d103541c152183d913a208 --- /dev/null +++ b/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 diff --git a/lib/payment_methods.rb b/lib/payment_methods.rb new file mode 100644 index 0000000000000000000000000000000000000000..374c69807041a6b13de2cf4357236f340e55de24 --- /dev/null +++ b/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 diff --git a/lib/plan.rb b/lib/plan.rb new file mode 100644 index 0000000000000000000000000000000000000000..10d9701f6f52a965978d767364a0ae844c05515a --- /dev/null +++ b/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 diff --git a/lib/transaction.rb b/lib/transaction.rb new file mode 100644 index 0000000000000000000000000000000000000000..f8bb2fbf2beedf9a46f0b19295cb12e556a119c6 --- /dev/null +++ b/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 diff --git a/lib/xep0122_field.rb b/lib/xep0122_field.rb new file mode 100644 index 0000000000000000000000000000000000000000..2ca613f198deb555f716ba474faa7ed496974854 --- /dev/null +++ b/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 diff --git a/sgx_jmp.rb b/sgx_jmp.rb index 5b35bfaa924d4847ab1283b4bf9c4bfe408e3923..06e61997228a481ea28db884a92f68a14c17117a 100644 --- a/sgx_jmp.rb +++ b/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 diff --git a/test/test_buy_account_credit_form.rb b/test/test_buy_account_credit_form.rb new file mode 100644 index 0000000000000000000000000000000000000000..da4d6d3def9e4ad0365aae6b575b7853e05dc25b --- /dev/null +++ b/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 diff --git a/test/test_customer.rb b/test/test_customer.rb new file mode 100644 index 0000000000000000000000000000000000000000..5477f5ae96259ab0dec00e097a29d3eb72e91ce5 --- /dev/null +++ b/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 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..b22013b7c7b7dd444baa475caa8000787cd8e6af --- /dev/null +++ b/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 diff --git a/test/test_ibr.rb b/test/test_ibr.rb new file mode 100644 index 0000000000000000000000000000000000000000..a64eb365417821f3ea817be1d7dd056a646ec1e1 --- /dev/null +++ b/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 diff --git a/test/test_payment_methods.rb b/test/test_payment_methods.rb new file mode 100644 index 0000000000000000000000000000000000000000..22698b9f2e58832cb15547c65f1dce3a277586c2 --- /dev/null +++ b/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 diff --git a/test/test_plan.rb b/test/test_plan.rb new file mode 100644 index 0000000000000000000000000000000000000000..acbbd52be57d0580fa41996eb8d36361d03fbc20 --- /dev/null +++ b/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 diff --git a/test/test_transaction.rb b/test/test_transaction.rb new file mode 100644 index 0000000000000000000000000000000000000000..5979ab59f0b2334d8b75538d07edc5419b69a748 --- /dev/null +++ b/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 diff --git a/test/test_xep0122_field.rb b/test/test_xep0122_field.rb new file mode 100644 index 0000000000000000000000000000000000000000..53dbcbfea494fc5cbd5c2695194fbf6a8a2842a9 --- /dev/null +++ b/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