Merge branch 'command-object'

Stephen Paul Weber created

* command-object:
  ErrorToSend => FinalStanza
  Since Command#finish causes an error, a then off the end won't work
  Refactor commands to have Command and Command::Execution objects

Change summary

.rubocop.yml                      |   4 
Gemfile                           |   4 
lib/bandwidth_tn_order.rb         |   4 
lib/command.rb                    | 158 +++++++++++
lib/command_list.rb               |  73 +---
lib/error_to_send.rb              |  10 
lib/polyfill.rb                   |   5 
lib/registration.rb               | 218 +++++++--------
lib/web_register_manager.rb       |  22 -
sgx_jmp.rb                        | 257 +++++++-----------
test/test_command_list.rb         |  47 ++
test/test_helper.rb               |  13 
test/test_registration.rb         | 448 +++++++++++++++++---------------
test/test_web_register_manager.rb |  15 
14 files changed, 689 insertions(+), 589 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -14,6 +14,10 @@ Metrics/MethodLength:
   Exclude:
     - test/*
 
+Metrics/BlockLength:
+  Exclude:
+    - test/*
+
 Metrics/ClassLength:
   Exclude:
     - test/*

Gemfile 🔗

@@ -10,12 +10,12 @@ gem "em-hiredis"
 gem "em-http-request"
 gem "em-pg-client", git: "https://github.com/royaltm/ruby-em-pg-client"
 gem "em-synchrony"
-gem "em_promise.rb", "~> 0.0.2"
+gem "em_promise.rb", "~> 0.0.3"
 gem "eventmachine"
 gem "money-open-exchange-rates"
 gem "ougai"
 gem "ruby-bandwidth-iris"
-gem "sentry-ruby"
+gem "sentry-ruby", "<= 4.3.1"
 gem "statsd-instrument", git: "https://github.com/singpolyma/statsd-instrument.git", branch: "graphite"
 gem "value_semantics", git: "https://github.com/singpolyma/value_semantics"
 

lib/bandwidth_tn_order.rb 🔗

@@ -8,7 +8,7 @@ require_relative "./catapult"
 
 class BandwidthTNOrder
 	def self.get(id)
-		EM.promise_fiber do
+		EMPromise.resolve(nil).then do
 			self.for(BandwidthIris::Order.get_order_response(
 				# https://github.com/Bandwidth/ruby-bandwidth-iris/issues/44
 				BandwidthIris::Client.new,
@@ -18,7 +18,7 @@ class BandwidthTNOrder
 	end
 
 	def self.create(tel, name: "sgx-jmp order #{tel}")
-		EM.promise_fiber do
+		EMPromise.resolve(nil).then do
 			Received.new(BandwidthIris::Order.create(
 				name: name,
 				site_id: CONFIG[:bandwidth_site],

lib/command.rb 🔗

@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+require "sentry-ruby"
+require "statsd-instrument"
+
+require_relative "customer_repo"
+
+class Command
+	def self.execution
+		Thread.current[:execution]
+	end
+
+	def self.reply(stanza=nil, &blk)
+		execution.reply(stanza, &blk)
+	end
+
+	def self.finish(*args, **kwargs, &blk)
+		execution.finish(*args, **kwargs, &blk)
+	end
+
+	def self.customer
+		execution.customer
+	end
+
+	def self.log
+		execution.log
+	end
+
+	class Execution
+		class FinalStanza
+			attr_reader :stanza
+
+			def initialize(stanza)
+				@stanza = stanza
+			end
+		end
+
+		attr_reader :customer_repo, :log, :iq
+
+		def initialize(customer_repo, blather, format_error, iq)
+			@customer_repo = customer_repo
+			@blather = blather
+			@format_error = format_error
+			@iq = iq
+			@log = LOG.child(node: iq.node)
+		end
+
+		def execute
+			StatsD.increment("command", tags: ["node:#{iq.node}"])
+			EMPromise.resolve(nil).then {
+				Thread.current[:execution] = self
+				sentry_hub
+				catch_after(yield self)
+			}.catch(&method(:panic))
+		end
+
+		def reply(stanza=nil)
+			stanza ||= iq.reply.tap do |reply|
+				reply.status = :executing
+			end
+			yield stanza if block_given?
+			COMMAND_MANAGER.write(stanza).then do |new_iq|
+				@iq = new_iq
+			end
+		end
+
+		def finish(text=nil, type: :info, status: :completed)
+			reply = @iq.reply
+			reply.status = status
+			yield reply if block_given?
+			if text
+				reply.note_type = type
+				reply.note_text = text
+			end
+			EMPromise.reject(FinalStanza.new(reply))
+		end
+
+		def sentry_hub
+			return @sentry_hub if @sentry_hub
+
+			# Stored on Fiber-local in 4.3.1 and earlier
+			# https://github.com/getsentry/sentry-ruby/issues/1495
+			@sentry_hub = Sentry.get_current_hub
+			raise "Sentry.init has not been called" unless @sentry_hub
+
+			@sentry_hub.push_scope
+			@sentry_hub.current_scope.clear_breadcrumbs
+			@sentry_hub.current_scope.set_transaction_name(@iq.node)
+			@sentry_hub.current_scope.set_user(jid: @iq.from.stripped.to_s)
+			@sentry_hub
+		end
+
+		def customer
+			@customer ||= @customer_repo.find_by_jid(@iq.from.stripped).then do |c|
+				sentry_hub.current_scope.set_user(
+					id: c.customer_id,
+					jid: @iq.from.stripped
+				)
+				c
+			end
+		end
+
+	protected
+
+		def catch_after(promise)
+			promise.catch_only(FinalStanza) { |e|
+				@blather << e.stanza
+			}.catch do |e|
+				log_error(e)
+				finish(
+					@format_error.call(e), type: :error
+				).catch_only(FinalStanza) do |to_send|
+					@blather << to_send.stanza
+				end
+			end
+		end
+
+		def log_error(e)
+			@log.error(
+				"Error raised during #{iq.node}: #{e.class}",
+				e
+			)
+			if e.is_a?(::Exception)
+				sentry_hub.capture_exception(e)
+			else
+				sentry_hub.capture_message(e.to_s)
+			end
+		end
+	end
+
+	attr_reader :node, :name
+
+	def initialize(
+		node,
+		name,
+		list_for: ->(tel:, **) { !!tel },
+		format_error: ->(e) { e.respond_to?(:message) ? e.message : e.to_s },
+		&blk
+	)
+		@node = node
+		@name = name
+		@list_for = list_for
+		@format_error = format_error
+		@blk = blk
+	end
+
+	def register(blather)
+		blather.command(:execute?, node: @node, sessionid: nil) do |iq|
+			customer_repo = CustomerRepo.new
+			Execution.new(customer_repo, blather, @format_error, iq).execute(&@blk)
+		end
+		self
+	end
+
+	def list_for?(**kwargs)
+		@list_for.call(**kwargs)
+	end
+end

lib/command_list.rb 🔗

@@ -3,65 +3,36 @@
 class CommandList
 	include Enumerable
 
-	def self.for(customer)
-		EMPromise.resolve(customer&.registered?).catch { nil }.then do |reg|
-			next Registered.for(customer, reg.phone) if reg
-			CommandList.new
-		end
+	def self.register(command)
+		@commands ||= []
+		@commands << command
 	end
 
-	def each
-		yield node: "jabber:iq:register", name: "Register"
-	end
-
-	class Registered < CommandList
-		def self.for(customer, tel)
-			EMPromise.all([
-				REDIS.get("catapult_fwd-#{tel}"),
-				customer.plan_name ? customer.payment_methods : []
-			]).then do |(fwd, payment_methods)|
-				Registered.new(*[
-					(HAS_CREDIT_CARD unless payment_methods.empty?),
-					(HAS_CURRENCY if customer.currency),
-					(HAS_FORWARDING if fwd)
-				].compact)
+	def self.for(customer)
+		EMPromise.resolve(customer&.registered?).catch { nil }.then do |reg|
+			args_for(customer, reg).then do |kwargs|
+				new(@commands.select { |c| c.list_for?(**kwargs) })
 			end
 		end
+	end
 
-		def initialize(*args)
-			@extra = args
-		end
-
-		ALWAYS = [
-			{ node: "number-display", name: "Display JMP Number" },
-			{ node: "configure-calls", name: "Configure Calls" },
-			{ node: "usage", name: "Show Monthly Usage" },
-			{ node: "reset sip account", name: "Create or Reset SIP Account" },
-			{
-				node: "credit cards",
-				name: "Credit Card Settings and Management"
-			}
-		].freeze
+	def self.args_for(customer, reg)
+		args = { customer: customer, tel: reg ? reg.phone : nil }
+		return EMPromise.resolve(args) unless args[:tel]
 
-		def each
-			super
-			([ALWAYS] + @extra).each do |commands|
-				commands.each { |x| yield x }
-			end
+		EMPromise.all([
+			REDIS.get("catapult_fwd-#{args[:tel]}"),
+			customer.plan_name ? customer.payment_methods : []
+		]).then do |(fwd, payment_methods)|
+			args.merge(fwd: fwd, payment_methods: payment_methods)
 		end
 	end
 
-	HAS_CURRENCY = [
-		node: "alt top up",
-		name: "Buy Account Credit by Bitcoin, Mail, or Interac eTransfer"
-	].freeze
-
-	HAS_FORWARDING = [
-		node: "record-voicemail-greeting",
-		name: "Record Voicemail Greeting"
-	].freeze
+	def initialize(commands)
+		@commands = commands
+	end
 
-	HAS_CREDIT_CARD = [
-		node: "top up", name: "Buy Account Credit by Credit Card"
-	].freeze
+	def each(&blk)
+		@commands.map { |c| { node: c.node, name: c.name } }.each(&blk)
+	end
 end

lib/error_to_send.rb 🔗

@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-class ErrorToSend < StandardError
-	attr_reader :stanza
-
-	def initialize(stanza)
-		super(stanza.to_s)
-		@stanza = stanza
-	end
-end

lib/polyfill.rb 🔗

@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class Object
+	alias then yield_self
+end

lib/registration.rb 🔗

@@ -5,61 +5,51 @@ require "ruby-bandwidth-iris"
 require "securerandom"
 
 require_relative "./alt_top_up_form"
+require_relative "./command"
 require_relative "./bandwidth_tn_order"
 require_relative "./em"
-require_relative "./error_to_send"
 require_relative "./oob"
 require_relative "./web_register_manager"
 
 class Registration
-	def self.for(iq, customer, web_register_manager)
+	def self.for(customer, web_register_manager)
+		jid = Command.execution.iq.from.stripped
 		customer.registered?.then do |registered|
 			if registered
-				Registered.new(iq, registered.phone)
+				Registered.new(registered.phone)
 			else
-				web_register_manager.choose_tel(iq).then do |(riq, tel)|
-					Activation.for(riq, customer, tel)
+				web_register_manager[jid].choose_tel.then do |tel|
+					Activation.for(customer, tel)
 				end
 			end
 		end
 	end
 
 	class Registered
-		def initialize(iq, tel)
-			@reply = iq.reply
-			@reply.status = :completed
+		def initialize(tel)
 			@tel = tel
 		end
 
 		def write
-			@reply.note_type = :info
-			@reply.note_text = <<~NOTE
-				You are already registered with JMP number #{@tel}
-			NOTE
-			BLATHER << @reply
-			nil
+			Command.finish("You are already registered with JMP number #{@tel}")
 		end
 	end
 
 	class Activation
-		def self.for(iq, customer, tel)
+		def self.for(customer, tel)
 			if customer.active?
-				Finish.new(iq, customer, tel)
+				Finish.new(customer, tel)
 			else
-				EMPromise.resolve(new(iq, customer, tel))
+				EMPromise.resolve(new(customer, tel))
 			end
 		end
 
-		def initialize(iq, customer, tel)
-			@reply = iq.reply
-			@reply.status = :executing
-			@reply.allowed_actions = [:next]
-
+		def initialize(customer, tel)
 			@customer = customer
 			@tel = tel
 		end
 
-		attr_reader :reply, :customer, :tel
+		attr_reader :customer, :tel
 
 		FORM_FIELDS = [
 			{
@@ -130,17 +120,16 @@ class Registration
 		end
 
 		def write
-			rate_center.then do |center|
-				form = reply.form
-				form.type = :form
-				form.title = "Activate JMP"
-				add_instructions(form, center)
-				form.fields = FORM_FIELDS
-
-				COMMAND_MANAGER.write(reply).then { |iq|
-					Payment.for(iq, customer, tel)
-				}.then(&:write)
-			end
+			rate_center.then { |center|
+				Command.reply do |reply|
+					reply.allowed_actions = [:next]
+					form = reply.form
+					form.type = :form
+					form.title = "Activate JMP"
+					add_instructions(form, center)
+					form.fields = FORM_FIELDS
+				end
+			}.then { |iq| Payment.for(iq, customer, tel) }.then(&:write)
 		end
 
 	protected
@@ -163,23 +152,19 @@ class Registration
 			customer = customer.with_plan(plan_name)
 			kinds.fetch(iq.form.field("activation_method")&.value&.to_s&.to_sym) {
 				raise "Invalid activation method"
-			}.call(iq, customer, tel)
+			}.call(customer, tel)
 		end
 
 		class Bitcoin
 			Payment.kinds[:bitcoin] = method(:new)
 
-			def initialize(iq, customer, tel)
-				@reply = iq.reply
-				reply.note_type = :info
-				reply.status = :canceled
-
+			def initialize(customer, tel)
 				@customer = customer
 				@customer_id = customer.customer_id
 				@tel = tel
 			end
 
-			attr_reader :reply, :customer_id, :tel
+			attr_reader :customer_id, :tel
 
 			def legacy_session_save
 				sid = SecureRandom.hex
@@ -215,9 +200,7 @@ class Registration
 					BTC_SELL_PRICES.public_send(@customer.currency.to_s.downcase)
 				]).then do |(addr, _, rate)|
 					min = CONFIG[:activation_amount] / rate
-					reply.note_text = note_text(min, addr)
-					BLATHER << reply
-					nil
+					Command.finish(note_text(min, addr), status: :canceled)
 				end
 			end
 
@@ -233,31 +216,23 @@ class Registration
 		class CreditCard
 			Payment.kinds[:credit_card] = ->(*args) { self.for(*args) }
 
-			def self.for(iq, customer, tel)
+			def self.for(customer, tel)
 				customer.payment_methods.then do |payment_methods|
 					if (method = payment_methods.default_payment_method)
-						Activate.new(iq, customer, method, tel)
+						Activate.new(customer, method, tel)
 					else
-						new(iq, customer, tel)
+						new(customer, tel)
 					end
 				end
 			end
 
-			def initialize(iq, customer, tel)
+			def initialize(customer, tel)
 				@customer = customer
 				@tel = tel
-
-				@reply = iq.reply
-				@reply.status = :executing
-				@reply.allowed_actions = [:next]
-				@reply.note_type = :info
-				@reply.note_text = "#{oob.desc}: #{oob.url}"
 			end
 
-			attr_reader :reply
-
-			def oob
-				oob = OOB.find_or_create(@reply.command)
+			def oob(reply)
+				oob = OOB.find_or_create(reply.command)
 				oob.url = CONFIG[:credit_card_url].call(
 					reply.to.stripped.to_s.gsub("\\", "%5C"),
 					@customer.customer_id
@@ -267,14 +242,17 @@ class Registration
 			end
 
 			def write
-				COMMAND_MANAGER.write(@reply).then do |riq|
-					CreditCard.for(riq, @customer, @tel).write
+				Command.reply { |reply|
+					reply.allowed_actions = [:next]
+					reply.note_type = :info
+					reply.note_text = "#{oob(reply).desc}: #{oob(reply).url}"
+				}.then do
+					CreditCard.for(@customer, @tel).then(&:write)
 				end
 			end
 
 			class Activate
-				def initialize(iq, customer, payment_method, tel)
-					@iq = iq
+				def initialize(customer, payment_method, tel)
 					@customer = customer
 					@payment_method = payment_method
 					@tel = tel
@@ -297,7 +275,7 @@ class Registration
 					tx.insert.then {
 						@customer.bill_plan
 					}.then do
-						Finish.new(@iq, @customer, @tel).write
+						Finish.new(@customer, @tel).write
 					end
 				end
 
@@ -323,14 +301,13 @@ class Registration
 				end
 
 				def declined
-					reply = @iq.reply
-					reply_oob = decline_oob(reply)
-					reply.status = :executing
-					reply.allowed_actions = [:next]
-					reply.note_type = :error
-					reply.note_text = "#{reply_oob.desc}: #{reply_oob.url}"
-					COMMAND_MANAGER.write(reply).then do |riq|
-						CreditCard.for(riq, @customer, @tel).write
+					Command.reply { |reply|
+						reply_oob = decline_oob(reply)
+						reply.allowed_actions = [:next]
+						reply.note_type = :error
+						reply.note_text = "#{reply_oob.desc}: #{reply_oob.url}"
+					}.then do
+						CreditCard.for(@customer, @tel).then(&:write)
 					end
 				end
 			end
@@ -348,45 +325,49 @@ class Registration
 				required: true
 			}].freeze
 
-			def initialize(iq, customer, tel, error: nil)
+			def initialize(customer, tel, error: nil)
 				@customer = customer
 				@tel = tel
-				@reply = iq.reply
-				@reply.status = :executing
-				@reply.allowed_actions = [:next]
-				@form = @reply.form
-				@form.type = :form
-				@form.title = "Enter Invite Code"
-				@form.instructions = error
-				@form.fields = FIELDS
+				@error = error
+			end
+
+			def add_form(reply)
+				form = reply.form
+				form.type = :form
+				form.title = "Enter Invite Code"
+				form.instructions = @error if @error
+				form.fields = FIELDS
 			end
 
 			def write
-				COMMAND_MANAGER.write(@reply).then do |iq|
-					guard_too_many_tries.then {
-						verify(iq.form.field("code")&.value&.to_s)
-					}.then {
-						Finish.new(iq, @customer, @tel)
-					}.catch_only(Invalid) { |e|
-						invalid_code(iq, e)
-					}.then(&:write)
-				end
+				Command.reply { |reply|
+					reply.allowed_actions = [:next]
+					add_form(reply)
+				}.then(&method(:parse))
+			end
+
+			def parse(iq)
+				guard_too_many_tries.then {
+					verify(iq.form.field("code")&.value&.to_s)
+				}.then {
+					Finish.new(@customer, @tel)
+				}.catch_only(Invalid, &method(:invalid_code)).then(&:write)
 			end
 
 		protected
 
 			def guard_too_many_tries
-				REDIS.get("jmp_invite_tries-#{@customer.customer_id}").then do |t|
+				REDIS.get("jmp_invite_tries-#{customer_id}").then do |t|
 					raise Invalid, "Too many wrong attempts" if t.to_i > 10
 				end
 			end
 
-			def invalid_code(iq, e)
+			def invalid_code(e)
 				EMPromise.all([
-					REDIS.incr("jmp_invite_tries-#{@customer.customer_id}").then do
-						REDIS.expire("jmp_invite_tries-#{@customer.customer_id}", 60 * 60)
+					REDIS.incr("jmp_invite_tries-#{customer_id}").then do
+						REDIS.expire("jmp_invite_tries-#{customer_id}", 60 * 60)
 					end,
-					InviteCode.new(iq, @customer, @tel, error: e.message)
+					InviteCode.new(@customer, @tel, error: e.message)
 				]).then(&:last)
 			end
 
@@ -395,7 +376,7 @@ class Registration
 			end
 
 			def verify(code)
-				EM.promise_fiber do
+				EMPromise.resolve(nil).then do
 					DB.transaction do
 						valid = DB.exec(<<~SQL, [customer_id, code]).cmd_tuples.positive?
 							UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP
@@ -411,10 +392,7 @@ class Registration
 		class Mail
 			Payment.kinds[:mail] = method(:new)
 
-			def initialize(iq, _customer, _tel)
-				@reply = iq.reply
-				@reply.status = :canceled
-			end
+			def initialize(_customer, _tel); end
 
 			def form
 				form = Blather::Stanza::X.new(:result)
@@ -437,18 +415,15 @@ class Registration
 			end
 
 			def write
-				@reply.command << form
-				BLATHER << @reply
+				Command.finish(status: :canceled) do |reply|
+					reply.command << form
+				end
 			end
 		end
 	end
 
 	class Finish
-		def initialize(iq, customer, tel)
-			@reply = iq.reply
-			@reply.status = :completed
-			@reply.note_type = :info
-			@reply.note_text = "Your JMP account has been activated as #{tel}"
+		def initialize(customer, tel)
 			@customer = customer
 			@tel = tel
 		end
@@ -457,11 +432,11 @@ class Registration
 			BandwidthTNOrder.create(@tel).then(&:poll).then(
 				->(_) { customer_active_tel_purchased },
 				lambda do |_|
-					@reply.note_type = :error
-					@reply.note_text =
+					Command.finish(
 						"The JMP number #{@tel} is no longer available, " \
-						"please visit https://jmp.chat and choose another."
-					BLATHER << @reply
+						"please visit https://jmp.chat and choose another.",
+						type: :error
+					)
 				end
 			)
 		end
@@ -469,27 +444,28 @@ class Registration
 	protected
 
 		def cheogram_sip_addr
-			"sip:#{ERB::Util.url_encode(@reply.to.stripped.to_s)}@sip.cheogram.com"
+			jid = Command.execution.iq.from.stripped
+			"sip:#{ERB::Util.url_encode(jid)}@sip.cheogram.com"
 		end
 
-		def raise_setup_error
-			@reply.note_type = :error
-			@reply.note_text =
+		def raise_setup_error(e)
+			Command.log.error "@customer.register! failed", e
+			Command.finish(
 				"There was an error setting up your number, " \
-				"please contact JMP support."
-			raise ErrorToSend, @reply
+				"please contact JMP support.",
+				type: :error
+			)
 		end
 
 		def customer_active_tel_purchased
-			@customer.register!(@tel).catch { |e|
-				LOG.error "@customer.register! failed", e
-				raise_setup_error
-			}.then {
+			@customer.register!(@tel).catch(&method(:raise_setup_error)).then {
 				EMPromise.all([
 					REDIS.set("catapult_fwd-#{@tel}", cheogram_sip_addr),
 					@customer.fwd_timeout = 25 # ~5 seconds / ring, 5 rings
 				])
-			}.then { BLATHER << @reply }
+			}.then do
+				Command.finish("Your JMP account has been activated as #{@tel}")
+			end
 		end
 	end
 end

lib/web_register_manager.rb 🔗

@@ -1,7 +1,5 @@
 # frozen_string_literal: true
 
-require_relative "error_to_send"
-
 class WebRegisterManager
 	def initialize
 		@tel_map = Hash.new { ChooseTel.new }
@@ -15,29 +13,23 @@ class WebRegisterManager
 		@tel_map[jid.to_s]
 	end
 
-	def choose_tel(iq)
-		self[iq&.from&.stripped].choose_tel(iq)
-	end
-
 	class HaveTel
 		def initialize(tel)
 			@tel = tel
 		end
 
-		def choose_tel(iq)
-			EMPromise.resolve([iq, @tel])
+		def choose_tel
+			EMPromise.resolve(@tel)
 		end
 	end
 
 	class ChooseTel
-		def choose_tel(iq)
-			reply = iq.reply
-			reply.status = :completed
-			reply.note_type = :error
-			reply.note_text =
+		def choose_tel
+			Command.finish(
 				"You have not chosen a phone number yet, please return to " \
-				"https://jmp.chat and choose one now."
-			raise ErrorToSend, reply
+				"https://jmp.chat and choose one now.",
+				type: :error
+			)
 		end
 	end
 end

sgx_jmp.rb 🔗

@@ -55,17 +55,18 @@ singleton_class.class_eval do
 	Blather::DSL.append_features(self)
 end
 
+require_relative "lib/polyfill"
 require_relative "lib/alt_top_up_form"
 require_relative "lib/add_bitcoin_address"
 require_relative "lib/backend_sgx"
 require_relative "lib/bandwidth_tn_order"
 require_relative "lib/btc_sell_prices"
 require_relative "lib/buy_account_credit_form"
+require_relative "lib/command"
 require_relative "lib/command_list"
 require_relative "lib/customer"
 require_relative "lib/customer_repo"
 require_relative "lib/electrum"
-require_relative "lib/error_to_send"
 require_relative "lib/em"
 require_relative "lib/payment_methods"
 require_relative "lib/registration"
@@ -132,7 +133,10 @@ end
 BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
 
 def panic(e, hub=nil)
-	LOG.fatal "Error raised during event loop: #{e.class}", e
+	(Thread.current[:log] || LOG).fatal(
+		"Error raised during event loop: #{e.class}",
+		e
+	)
 	if e.is_a?(::Exception)
 		(hub || Sentry).capture_exception(e, hint: { background: false })
 	else
@@ -389,174 +393,125 @@ iq "/iq/ns:services", ns: "urn:xmpp:extdisco:2" do |iq|
 	self << reply
 end
 
-command :execute?, node: "jabber:iq:register", sessionid: nil do |iq|
-	StatsD.increment("command", tags: ["node:#{iq.node}"])
-
-	sentry_hub = new_sentry_hub(iq, name: iq.node)
-	EMPromise.resolve(nil).then {
-		CustomerRepo.new.find_by_jid(iq.from.stripped)
-	}.catch {
-		sentry_hub.add_breadcrumb(Sentry::Breadcrumb.new(
-			message: "Customer.create"
-		))
-		CustomerRepo.new.create(iq.from.stripped)
+Command.new(
+	"jabber:iq:register",
+	"Register",
+	list_for: ->(*) { true }
+) {
+	Command.customer.catch {
+		Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Customer.create"))
+		Command.execution.customer_repo.create(Command.execution.iq.from.stripped)
 	}.then { |customer|
-		sentry_hub.current_scope.set_user(
-			id: customer.customer_id,
-			jid: iq.from.stripped.to_s
-		)
-		sentry_hub.add_breadcrumb(Sentry::Breadcrumb.new(
-			message: "Registration.for"
-		))
-		Registration.for(
-			iq,
-			customer,
-			web_register_manager
-		).then(&:write).then { StatsD.increment("registration.completed") }
-	}.catch_only(ErrorToSend) { |e|
-		self << e.stanza
-	}.catch { |e| panic(e, sentry_hub) }
-end
-
-def reply_with_note(iq, text, type: :info)
-	reply = iq.reply
-	reply.status = :completed
-	reply.note_type = type
-	reply.note_text = text
-
-	self << reply
-end
+		Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Registration.for"))
+		Registration.for(customer, web_register_manager).then(&:write)
+	}.then {
+		StatsD.increment("registration.completed")
+	}.catch_only(Command::Execution::FinalStanza) do |e|
+		StatsD.increment("registration.completed")
+		EMPromise.reject(e)
+	end
+}.register(self).then(&CommandList.method(:register))
 
 # Commands that just pass through to the SGX
-command node: [
-	"number-display",
-	"configure-calls",
-	"record-voicemail-greeting"
-] do |iq|
-	StatsD.increment("command", tags: ["node:#{iq.node}"])
-
-	sentry_hub = new_sentry_hub(iq, name: iq.node)
-	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
-		sentry_hub.current_scope.set_user(
-			id: customer.customer_id,
-			jid: iq.from.stripped.to_s
-		)
-
-		customer.stanza_from(iq)
-	}.catch { |e| panic(e, sentry_hub) }
+{
+	"number-display" => ["Display JMP Number"],
+	"configure-calls" => ["Configure Calls"],
+	"record-voicemail-greeting" => [
+		"Record Voicemail Greeting",
+		list_for: ->(fwd: nil, **) { !!fwd }
+	]
+}.each do |node, args|
+	Command.new(node, *args) {
+		Command.customer.then do |customer|
+			customer.stanza_from(Command.execution.iq)
+		end
+	}.register(self).then(&CommandList.method(:register))
 end
 
-command :execute?, node: "credit cards", sessionid: nil do |iq|
-	StatsD.increment("command", tags: ["node:#{iq.node}"])
-
-	sentry_hub = new_sentry_hub(iq, name: iq.node)
-	reply = iq.reply
-	reply.status = :completed
-
-	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
-		oob = OOB.find_or_create(reply.command)
-		oob.url = CONFIG[:credit_card_url].call(
+Command.new(
+	"credit cards",
+	"Credit Card Settings and Management"
+) {
+	Command.customer.then do |customer|
+		url = CONFIG[:credit_card_url].call(
 			reply.to.stripped.to_s.gsub("\\", "%5C"),
 			customer.customer_id
 		)
-		oob.desc = "Manage credits cards and settings"
-
-		reply.note_type = :info
-		reply.note_text = "#{oob.desc}: #{oob.url}"
-
-		self << reply
-	}.catch { |e| panic(e, sentry_hub) }
-end
-
-command :execute?, node: "top up", sessionid: nil do |iq|
-	StatsD.increment("command", tags: ["node:#{iq.node}"])
-
-	sentry_hub = new_sentry_hub(iq, name: iq.node)
-	reply = iq.reply
-	reply.allowed_actions = [:complete]
-
-	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
+		desc = "Manage credits cards and settings"
+		Command.finish("#{desc}: #{url}") do |reply|
+			oob = OOB.find_or_create(reply.command)
+			oob.url = url
+			oob.desc = desc
+		end
+	end
+}.register(self).then(&CommandList.method(:register))
+
+Command.new(
+	"top up",
+	"Buy Account Credit by Credit Card",
+	list_for: ->(payment_methods: [], **) { !payment_methods.empty? },
+	format_error: ->(e) { "Failed to buy credit, system said: #{e.message}" }
+) {
+	Command.customer.then { |customer|
 		BuyAccountCreditForm.for(customer).then do |credit_form|
-			credit_form.add_to_form(reply.form)
-			COMMAND_MANAGER.write(reply).then { |iq2| [customer, credit_form, iq2] }
+			Command.reply { |reply|
+				reply.allowed_actions = [:complete]
+				credit_form.add_to_form(reply.form)
+			}.then do |iq|
+				Transaction.sale(customer, **credit_form.parse(iq.form))
+			end
 		end
-	}.then { |(customer, credit_form, iq2)|
-		iq = iq2 # This allows the catch to use it also
-		Transaction.sale(customer, **credit_form.parse(iq2.form))
 	}.then { |transaction|
 		transaction.insert.then do
-			reply_with_note(iq, "#{transaction} added to your account balance.")
+			Command.finish("#{transaction} added to your account balance.")
 		end
-	}.catch_only(BuyAccountCreditForm::AmountValidationError) { |e|
-		reply_with_note(iq, e.message, type: :error)
-	}.catch { |e|
-		sentry_hub.capture_exception(e)
-		text = "Failed to buy credit, system said: #{e.message}"
-		reply_with_note(iq, text, type: :error)
-	}.catch { |e| panic(e, sentry_hub) }
-end
-
-command :execute?, node: "alt top up", sessionid: nil do |iq|
-	StatsD.increment("command", tags: ["node:#{iq.node}"])
-
-	sentry_hub = new_sentry_hub(iq, name: iq.node)
-	reply = iq.reply
-	reply.status = :executing
-	reply.allowed_actions = [:complete]
-
-	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
-		sentry_hub.current_scope.set_user(
-			id: customer.customer_id,
-			jid: iq.from.stripped.to_s
-		)
-
+	}.catch_only(BuyAccountCreditForm::AmountValidationError) do |e|
+		Command.finish(e.message, type: :error)
+	end
+}.register(self).then(&CommandList.method(:register))
+
+Command.new(
+	"alt top up",
+	"Buy Account Credit by Bitcoin, Mail, or Interac eTransfer",
+	list_for: ->(customer:, **) { !!customer.currency }
+) {
+	Command.customer.then { |customer|
 		EMPromise.all([AltTopUpForm.for(customer), customer])
-	}.then { |(alt_form, customer)|
-		reply.command << alt_form.form
-
-		COMMAND_MANAGER.write(reply).then do |iq2|
-			AddBitcoinAddress.for(iq2, alt_form, customer).write
+	}.then do |(alt_form, customer)|
+		Command.reply { |reply|
+			reply.allowed_actions = [:complete]
+			reply.command << alt_form.form
+		}.then do |iq|
+			AddBitcoinAddress.for(iq, alt_form, customer).write
 		end
-	}.catch { |e| panic(e, sentry_hub) }
-end
-
-command :execute?, node: "reset sip account", sessionid: nil do |iq|
-	StatsD.increment("command", tags: ["node:#{iq.node}"])
-
-	sentry_hub = new_sentry_hub(iq, name: iq.node)
-	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
-		sentry_hub.current_scope.set_user(
-			id: customer.customer_id,
-			jid: iq.from.stripped.to_s
-		)
-		customer.reset_sip_account
-	}.then { |sip_account|
-		reply = iq.reply
-		reply.command << sip_account.form
-		BLATHER << reply
-	}.catch { |e| panic(e, sentry_hub) }
-end
-
-command :execute?, node: "usage", sessionid: nil do |iq|
-	StatsD.increment("command", tags: ["node:#{iq.node}"])
+	end
+}.register(self).then(&CommandList.method(:register))
+
+Command.new(
+	"reset sip account",
+	"Create or Reset SIP Account"
+) {
+	Command.customer.then(&:reset_sip_account).then do |sip_account|
+		Command.finish do |reply|
+			reply.command << sip_account.form
+		end
+	end
+}.register(self).then(&CommandList.method(:register))
 
-	sentry_hub = new_sentry_hub(iq, name: iq.node)
+Command.new(
+	"usage",
+	"Show Monthly Usage"
+) {
 	report_for = (Date.today..(Date.today << 1))
 
-	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
-		sentry_hub.current_scope.set_user(
-			id: customer.customer_id,
-			jid: iq.from.stripped.to_s
-		)
-
+	Command.customer.then { |customer|
 		customer.usage_report(report_for)
-	}.then { |usage_report|
-		reply = iq.reply
-		reply.status = :completed
-		reply.command << usage_report.form
-		BLATHER << reply
-	}.catch { |e| panic(e, sentry_hub) }
-end
+	}.then do |usage_report|
+		Command.finish do |reply|
+			reply.command << usage_report.form
+		end
+	end
+}.register(self).then(&CommandList.method(:register))
 
 command :execute?, node: "web-register", sessionid: nil do |iq|
 	StatsD.increment("command", tags: ["node:#{iq.node}"])

test/test_command_list.rb 🔗

@@ -1,20 +1,45 @@
 # frozen_string_literal: true
 
 require "test_helper"
+require "command"
 require "command_list"
 
 CommandList::Customer = Minitest::Mock.new
 CommandList::REDIS = Minitest::Mock.new
 
 class CommandListTest < Minitest::Test
+	SETUP = begin
+		[
+			Command.new("no_customer", "", list_for: ->(**) { true }),
+			Command.new("registered", "", list_for: ->(tel:, **) { !!tel }),
+			Command.new("fwd", "", list_for: ->(fwd: nil, **) { !!fwd }),
+			Command.new(
+				"currency", "",
+				list_for: ->(customer: nil, **) { !!customer&.currency }
+			),
+			Command.new(
+				"cc", "",
+				list_for: ->(payment_methods: [], **) { !payment_methods.empty? }
+			)
+		].each do |c|
+			CommandList.register(c)
+		end
+	end
+
 	def test_for_no_customer
-		assert_instance_of CommandList, CommandList.for(nil).sync
+		assert_equal(
+			["no_customer"],
+			CommandList.for(nil).sync.map { |c| c[:node] }
+		)
 	end
 	em :test_for_no_customer
 
 	def test_for_unregistered
 		customer = OpenStruct.new(registered?: false)
-		assert_instance_of CommandList, CommandList.for(customer).sync
+		assert_equal(
+			["no_customer"],
+			CommandList.for(customer).sync.map { |c| c[:node] }
+		)
 	end
 	em :test_for_unregistered
 
@@ -29,9 +54,8 @@ class CommandListTest < Minitest::Test
 			payment_methods: EMPromise.resolve([])
 		)
 		assert_equal(
-			["CommandList::Registered"],
-			CommandList.for(customer).sync
-			.class.ancestors.map(&:name).grep(/\ACommandList::/)
+			["no_customer", "registered"],
+			CommandList.for(customer).sync.map { |c| c[:node] }
 		)
 	end
 	em :test_for_registered
@@ -47,8 +71,8 @@ class CommandListTest < Minitest::Test
 			payment_methods: EMPromise.resolve([])
 		)
 		assert_equal(
-			CommandList::HAS_FORWARDING,
-			CommandList::HAS_FORWARDING & CommandList.for(customer).sync.to_a
+			["no_customer", "registered", "fwd"],
+			CommandList.for(customer).sync.map { |c| c[:node] }
 		)
 	end
 	em :test_for_registered_with_fwd
@@ -65,8 +89,8 @@ class CommandListTest < Minitest::Test
 			payment_methods: EMPromise.resolve([:boop])
 		)
 		assert_equal(
-			CommandList::HAS_CREDIT_CARD,
-			CommandList::HAS_CREDIT_CARD & CommandList.for(customer).sync.to_a
+			["no_customer", "registered", "cc"],
+			CommandList.for(customer).sync.map { |c| c[:node] }
 		)
 	end
 	em :test_for_registered_with_credit_card
@@ -81,10 +105,9 @@ class CommandListTest < Minitest::Test
 			registered?: OpenStruct.new(phone: "1"),
 			currency: :USD
 		)
-
 		assert_equal(
-			CommandList::HAS_CURRENCY,
-			CommandList::HAS_CURRENCY & CommandList.for(customer).sync.to_a
+			["no_customer", "registered", "currency"],
+			CommandList.for(customer).sync.map { |c| c[:node] }
 		)
 	end
 	em :test_for_registered_with_currency

test/test_helper.rb 🔗

@@ -10,6 +10,7 @@ require "em_promise"
 require "fiber"
 require "minitest/autorun"
 require "rantly/minitest_extensions"
+require "sentry-ruby"
 require "webmock/minitest"
 begin
 	require "pry-rescue/minitest"
@@ -34,6 +35,8 @@ end
 
 require "backend_sgx"
 
+Sentry.init
+
 CONFIG = {
 	sgx: "sgx",
 	component: {
@@ -78,6 +81,16 @@ CONFIG = {
 	electrum_notify_url: ->(*) { "http://notify.example.com" }
 }.freeze
 
+def panic(e)
+	raise e
+end
+
+LOG = Class.new {
+	def child(*)
+		Minitest::Mock.new
+	end
+}.new.freeze
+
 BLATHER = Class.new {
 	def <<(*); end
 }.new.freeze

test/test_registration.rb 🔗

@@ -4,6 +4,19 @@ require "test_helper"
 require "customer"
 require "registration"
 
+def execute_command(
+	iq=Blather::Stanza::Iq::Command.new.tap { |i| i.from = "test@example.com" },
+	blather: BLATHER,
+	&blk
+)
+	Command::Execution.new(
+		Minitest::Mock.new,
+		blather,
+		:to_s.to_proc,
+		iq
+	).execute(&blk).sync
+end
+
 class RegistrationTest < Minitest::Test
 	def test_for_registered
 		sgx = OpenStruct.new(
@@ -11,11 +24,12 @@ class RegistrationTest < Minitest::Test
 		)
 		iq = Blather::Stanza::Iq::Command.new
 		iq.from = "test@example.com"
-		result = Registration.for(
-			iq,
-			Customer.new("test", sgx: sgx),
-			Minitest::Mock.new
-		).sync
+		result = execute_command(iq) do
+			Registration.for(
+				Customer.new("test", sgx: sgx),
+				Minitest::Mock.new
+			)
+		end
 		assert_kind_of Registration::Registered, result
 	end
 	em :test_for_registered
@@ -26,16 +40,17 @@ class RegistrationTest < Minitest::Test
 		web_manager["test@example.com"] = "+15555550000"
 		iq = Blather::Stanza::Iq::Command.new
 		iq.from = "test@example.com"
-		result = Registration.for(
-			iq,
-			Customer.new(
-				"test",
-				plan_name: "test_usd",
-				expires_at: Time.now + 999,
-				sgx: sgx
-			),
-			web_manager
-		).sync
+		result = execute_command(iq) do
+			Registration.for(
+				Customer.new(
+					"test",
+					plan_name: "test_usd",
+					expires_at: Time.now + 999,
+					sgx: sgx
+				),
+				web_manager
+			)
+		end
 		assert_kind_of Registration::Finish, result
 	end
 	em :test_for_activated
@@ -46,20 +61,20 @@ class RegistrationTest < Minitest::Test
 		web_manager["test@example.com"] = "+15555550000"
 		iq = Blather::Stanza::Iq::Command.new
 		iq.from = "test@example.com"
-		result = Registration.for(
-			iq,
-			Customer.new("test", sgx: sgx),
-			web_manager
-		).sync
+		result = execute_command(iq) do
+			Registration.for(
+				Customer.new("test", sgx: sgx),
+				web_manager
+			)
+		end
 		assert_kind_of Registration::Activation, result
 	end
 	em :test_for_not_activated_with_customer_id
 
 	class ActivationTest < Minitest::Test
-		Registration::Activation::COMMAND_MANAGER = Minitest::Mock.new
+		Command::COMMAND_MANAGER = Minitest::Mock.new
 		def setup
-			iq = Blather::Stanza::Iq::Command.new
-			@activation = Registration::Activation.new(iq, "test", "+15555550000")
+			@activation = Registration::Activation.new("test", "+15555550000")
 		end
 
 		def test_write
@@ -82,12 +97,9 @@ class RegistrationTest < Minitest::Test
 					</TelephoneNumberDetails>
 				</TelephoneNumberResponse>
 			RESPONSE
-			result = Minitest::Mock.new
-			result.expect(:then, result)
-			result.expect(:then, EMPromise.resolve(:test_result))
-			Registration::Activation::COMMAND_MANAGER.expect(
+			Command::COMMAND_MANAGER.expect(
 				:write,
-				result,
+				EMPromise.reject(:test_result),
 				[Matching.new do |iq|
 					assert_equal :form, iq.form.type
 					assert_equal(
@@ -96,7 +108,11 @@ class RegistrationTest < Minitest::Test
 					)
 				end]
 			)
-			assert_equal :test_result, @activation.write.sync
+			assert_equal(
+				:test_result,
+				execute_command { @activation.write.catch { |e| e } }
+			)
+			assert_mock Command::COMMAND_MANAGER
 		end
 		em :test_write
 	end
@@ -161,7 +177,6 @@ class RegistrationTest < Minitest::Test
 
 		class BitcoinTest < Minitest::Test
 			Registration::Payment::Bitcoin::BTC_SELL_PRICES = Minitest::Mock.new
-			Registration::Payment::Bitcoin::BLATHER = Minitest::Mock.new
 			Customer::REDIS = Minitest::Mock.new
 
 			def setup
@@ -172,9 +187,7 @@ class RegistrationTest < Minitest::Test
 					:add_btc_address,
 					EMPromise.resolve("testaddr")
 				)
-				iq = Blather::Stanza::Iq::Command.new
 				@bitcoin = Registration::Payment::Bitcoin.new(
-					iq,
 					@customer,
 					"+15555550000"
 				)
@@ -192,7 +205,8 @@ class RegistrationTest < Minitest::Test
 
 					You will receive a notification when your payment is complete.
 				NOTE
-				Registration::Payment::Bitcoin::BLATHER.expect(
+				blather = Minitest::Mock.new
+				blather.expect(
 					:<<,
 					nil,
 					[Matching.new do |reply|
@@ -207,19 +221,18 @@ class RegistrationTest < Minitest::Test
 					EMPromise.resolve(BigDecimal.new(1))
 				)
 				@bitcoin.stub(:save, EMPromise.resolve(nil)) do
-					@bitcoin.write.sync
+					execute_command(blather: blather) do
+						@bitcoin.write
+					end
 				end
-				Registration::Payment::Bitcoin::BLATHER.verify
+				assert_mock blather
 			end
 			em :test_write
 		end
 
 		class CreditCardTest < Minitest::Test
 			def setup
-				@iq = Blather::Stanza::Iq::Command.new
-				@iq.from = "test@example.com"
 				@credit_card = Registration::Payment::CreditCard.new(
-					@iq,
 					Customer.new("test"),
 					"+15555550000"
 				)
@@ -234,7 +247,6 @@ class RegistrationTest < Minitest::Test
 				assert_kind_of(
 					Registration::Payment::CreditCard::Activate,
 					Registration::Payment::CreditCard.for(
-						@iq,
 						customer,
 						"+15555550000"
 					).sync
@@ -242,14 +254,27 @@ class RegistrationTest < Minitest::Test
 			end
 			em :test_for
 
-			def test_reply
-				assert_equal [:execute, :next], @credit_card.reply.allowed_actions
-				assert_equal(
-					"Add credit card, then return here to continue: " \
-					"http://creditcard.example.com",
-					@credit_card.reply.note.content
-				)
+			def test_write
+				result = execute_command do
+					Command::COMMAND_MANAGER.expect(
+						:write,
+						EMPromise.reject(:test_result),
+						[Matching.new do |reply|
+							assert_equal [:execute, :next], reply.allowed_actions
+							assert_equal(
+								"Add credit card, then return here to continue: " \
+								"http://creditcard.example.com",
+								reply.note.content
+							)
+						end]
+					)
+
+					@credit_card.write.catch { |e| e }
+				end
+
+				assert_equal :test_result, result
 			end
+			em :test_write
 		end
 
 		class ActivateTest < Minitest::Test
@@ -257,8 +282,7 @@ class RegistrationTest < Minitest::Test
 				Minitest::Mock.new
 			Registration::Payment::CreditCard::Activate::Transaction =
 				Minitest::Mock.new
-			Registration::Payment::CreditCard::Activate::COMMAND_MANAGER =
-				Minitest::Mock.new
+			Command::COMMAND_MANAGER = Minitest::Mock.new
 
 			def test_write
 				transaction = PromiseMock.new
@@ -277,19 +301,19 @@ class RegistrationTest < Minitest::Test
 					assert_equal CONFIG[:activation_amount], amount
 					assert_equal :test_default_method, payment_method
 				end
-				iq = Blather::Stanza::Iq::Command.new
 				customer.expect(:bill_plan, nil)
 				Registration::Payment::CreditCard::Activate::Finish.expect(
 					:new,
 					OpenStruct.new(write: nil),
-					[Blather::Stanza::Iq, customer, "+15555550000"]
+					[customer, "+15555550000"]
 				)
-				Registration::Payment::CreditCard::Activate.new(
-					iq,
-					customer,
-					:test_default_method,
-					"+15555550000"
-				).write.sync
+				execute_command do
+					Registration::Payment::CreditCard::Activate.new(
+						customer,
+						:test_default_method,
+						"+15555550000"
+					).write
+				end
 				Registration::Payment::CreditCard::Activate::Transaction.verify
 				transaction.verify
 				customer.verify
@@ -301,21 +325,11 @@ class RegistrationTest < Minitest::Test
 				customer = Minitest::Mock.new(
 					Customer.new("test", plan_name: "test_usd")
 				)
-				Registration::Payment::CreditCard::Activate::Transaction.expect(
-					:sale,
-					EMPromise.reject("declined")
-				) do |acustomer, amount:, payment_method:|
-					assert_operator customer, :===, acustomer
-					assert_equal CONFIG[:activation_amount], amount
-					assert_equal :test_default_method, payment_method
-				end
 				iq = Blather::Stanza::Iq::Command.new
 				iq.from = "test@example.com"
-				result = Minitest::Mock.new
-				result.expect(:then, nil)
-				Registration::Payment::CreditCard::Activate::COMMAND_MANAGER.expect(
+				Command::COMMAND_MANAGER.expect(
 					:write,
-					result,
+					EMPromise.reject(:test_result),
 					[Matching.new do |reply|
 						assert_equal :error, reply.note_type
 						assert_equal(
@@ -325,12 +339,23 @@ class RegistrationTest < Minitest::Test
 						)
 					end]
 				)
-				Registration::Payment::CreditCard::Activate.new(
-					iq,
-					customer,
-					:test_default_method,
-					"+15555550000"
-				).write.sync
+				result = execute_command do
+					Registration::Payment::CreditCard::Activate::Transaction.expect(
+						:sale,
+						EMPromise.reject("declined")
+					) do |acustomer, amount:, payment_method:|
+						assert_operator customer, :===, acustomer
+						assert_equal CONFIG[:activation_amount], amount
+						assert_equal :test_default_method, payment_method
+					end
+
+					Registration::Payment::CreditCard::Activate.new(
+						customer,
+						:test_default_method,
+						"+15555550000"
+					).write.catch { |e| e }
+				end
+				assert_equal :test_result, result
 				Registration::Payment::CreditCard::Activate::Transaction.verify
 			end
 			em :test_write_declines
@@ -341,164 +366,157 @@ class RegistrationTest < Minitest::Test
 				Minitest::Mock.new
 			Registration::Payment::InviteCode::REDIS =
 				Minitest::Mock.new
-			Registration::Payment::InviteCode::COMMAND_MANAGER =
-				Minitest::Mock.new
+			Command::COMMAND_MANAGER = Minitest::Mock.new
 			Registration::Payment::InviteCode::Finish =
 				Minitest::Mock.new
-
 			def test_write
 				customer = Customer.new("test", plan_name: "test_usd")
-				Registration::Payment::InviteCode::REDIS.expect(
-					:get,
-					EMPromise.resolve(nil),
-					["jmp_invite_tries-test"]
-				)
-				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
-					:write,
-					EMPromise.resolve(
-						Blather::Stanza::Iq::Command.new.tap { |iq|
-							iq.form.fields = [{ var: "code", value: "abc" }]
-						}
-					),
-					[Matching.new do |reply|
-						assert_equal :form, reply.form.type
-						assert_nil reply.form.instructions
-					end]
-				)
 				Registration::Payment::InviteCode::DB.expect(:transaction, true, [])
 				Registration::Payment::InviteCode::Finish.expect(
 					:new,
 					OpenStruct.new(write: nil),
 					[
-						Blather::Stanza::Iq::Command,
 						customer,
 						"+15555550000"
 					]
 				)
-				iq = Blather::Stanza::Iq::Command.new
-				iq.from = "test@example.com"
-				Registration::Payment::InviteCode.new(
-					iq,
-					customer,
-					"+15555550000"
-				).write.sync
-				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
-				Registration::Payment::InviteCode::DB.verify
-				Registration::Payment::InviteCode::REDIS.verify
-				Registration::Payment::InviteCode::Finish.verify
+				execute_command do
+					Registration::Payment::InviteCode::REDIS.expect(
+						:get,
+						EMPromise.resolve(nil),
+						["jmp_invite_tries-test"]
+					)
+					Command::COMMAND_MANAGER.expect(
+						:write,
+						EMPromise.resolve(
+							Blather::Stanza::Iq::Command.new.tap { |iq|
+								iq.form.fields = [{ var: "code", value: "abc" }]
+							}
+						),
+						[Matching.new do |reply|
+							assert_equal :form, reply.form.type
+							assert_nil reply.form.instructions
+						end]
+					)
+
+					Registration::Payment::InviteCode.new(
+						customer,
+						"+15555550000"
+					).write
+				end
+				assert_mock Command::COMMAND_MANAGER
+				assert_mock Registration::Payment::InviteCode::DB
+				assert_mock Registration::Payment::InviteCode::REDIS
+				assert_mock Registration::Payment::InviteCode::Finish
 			end
 			em :test_write
 
 			def test_write_bad_code
-				customer = Customer.new("test", plan_name: "test_usd")
-				Registration::Payment::InviteCode::REDIS.expect(
-					:get,
-					EMPromise.resolve(0),
-					["jmp_invite_tries-test"]
-				)
-				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
-					:write,
-					EMPromise.resolve(
-						Blather::Stanza::Iq::Command.new.tap { |iq|
-							iq.form.fields = [{ var: "code", value: "abc" }]
-						}
-					),
-					[Matching.new do |reply|
-						assert_equal :form, reply.form.type
-						assert_nil reply.form.instructions
-					end]
-				)
-				Registration::Payment::InviteCode::DB.expect(:transaction, []) do
-					raise Registration::Payment::InviteCode::Invalid, "wut"
-				end
-				Registration::Payment::InviteCode::REDIS.expect(
-					:incr,
-					EMPromise.resolve(nil),
-					["jmp_invite_tries-test"]
-				)
-				Registration::Payment::InviteCode::REDIS.expect(
-					:expire,
-					EMPromise.resolve(nil),
-					["jmp_invite_tries-test", 60 * 60]
-				)
-				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
-					:write,
-					EMPromise.reject(Promise::Error.new),
-					[Matching.new do |reply|
-						assert_equal :form, reply.form.type
-						assert_equal "wut", reply.form.instructions
-					end]
-				)
-				iq = Blather::Stanza::Iq::Command.new
-				iq.from = "test@example.com"
-				assert_raises Promise::Error do
+				result = execute_command do
+					customer = Customer.new("test", plan_name: "test_usd")
+					Registration::Payment::InviteCode::REDIS.expect(
+						:get,
+						EMPromise.resolve(0),
+						["jmp_invite_tries-test"]
+					)
+					Registration::Payment::InviteCode::DB.expect(:transaction, []) do
+						raise Registration::Payment::InviteCode::Invalid, "wut"
+					end
+					Registration::Payment::InviteCode::REDIS.expect(
+						:incr,
+						EMPromise.resolve(nil),
+						["jmp_invite_tries-test"]
+					)
+					Registration::Payment::InviteCode::REDIS.expect(
+						:expire,
+						EMPromise.resolve(nil),
+						["jmp_invite_tries-test", 60 * 60]
+					)
+					Command::COMMAND_MANAGER.expect(
+						:write,
+						EMPromise.resolve(
+							Blather::Stanza::Iq::Command.new.tap { |iq|
+								iq.form.fields = [{ var: "code", value: "abc" }]
+							}
+						),
+						[Matching.new do |reply|
+							assert_equal :form, reply.form.type
+							assert_nil reply.form.instructions
+						end]
+					)
+					Command::COMMAND_MANAGER.expect(
+						:write,
+						EMPromise.reject(:test_result),
+						[Matching.new do |reply|
+							assert_equal :form, reply.form.type
+							assert_equal "wut", reply.form.instructions
+						end]
+					)
+
 					Registration::Payment::InviteCode.new(
-						iq,
 						customer,
 						"+15555550000"
-					).write.sync
+					).write.catch { |e| e }
 				end
-				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
-				Registration::Payment::InviteCode::DB.verify
-				Registration::Payment::InviteCode::REDIS.verify
+				assert_equal :test_result, result
+				assert_mock Command::COMMAND_MANAGER
+				assert_mock Registration::Payment::InviteCode::DB
+				assert_mock Registration::Payment::InviteCode::REDIS
 			end
 			em :test_write_bad_code
 
 			def test_write_bad_code_over_limit
-				customer = Customer.new("test", plan_name: "test_usd")
-				Registration::Payment::InviteCode::REDIS.expect(
-					:get,
-					EMPromise.resolve(11),
-					["jmp_invite_tries-test"]
-				)
-				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
-					:write,
-					EMPromise.resolve(
-						Blather::Stanza::Iq::Command.new.tap { |iq|
-							iq.form.fields = [{ var: "code", value: "abc" }]
-						}
-					),
-					[Matching.new do |reply|
-						assert_equal :form, reply.form.type
-						assert_nil reply.form.instructions
-					end]
-				)
-				Registration::Payment::InviteCode::REDIS.expect(
-					:incr,
-					EMPromise.resolve(nil),
-					["jmp_invite_tries-test"]
-				)
-				Registration::Payment::InviteCode::REDIS.expect(
-					:expire,
-					EMPromise.resolve(nil),
-					["jmp_invite_tries-test", 60 * 60]
-				)
-				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
-					:write,
-					EMPromise.reject(Promise::Error.new),
-					[Matching.new do |reply|
-						assert_equal :form, reply.form.type
-						assert_equal "Too many wrong attempts", reply.form.instructions
-					end]
-				)
-				iq = Blather::Stanza::Iq::Command.new
-				iq.from = "test@example.com"
-				assert_raises Promise::Error do
+				result = execute_command do
+					customer = Customer.new("test", plan_name: "test_usd")
+					Registration::Payment::InviteCode::REDIS.expect(
+						:get,
+						EMPromise.resolve(11),
+						["jmp_invite_tries-test"]
+					)
+					Command::COMMAND_MANAGER.expect(
+						:write,
+						EMPromise.resolve(
+							Blather::Stanza::Iq::Command.new.tap { |iq|
+								iq.form.fields = [{ var: "code", value: "abc" }]
+							}
+						),
+						[Matching.new do |reply|
+							assert_equal :form, reply.form.type
+							assert_nil reply.form.instructions
+						end]
+					)
+					Registration::Payment::InviteCode::REDIS.expect(
+						:incr,
+						EMPromise.resolve(nil),
+						["jmp_invite_tries-test"]
+					)
+					Registration::Payment::InviteCode::REDIS.expect(
+						:expire,
+						EMPromise.resolve(nil),
+						["jmp_invite_tries-test", 60 * 60]
+					)
+					Command::COMMAND_MANAGER.expect(
+						:write,
+						EMPromise.reject(:test_result),
+						[Matching.new do |reply|
+							assert_equal :form, reply.form.type
+							assert_equal "Too many wrong attempts", reply.form.instructions
+						end]
+					)
 					Registration::Payment::InviteCode.new(
-						iq,
 						customer,
 						"+15555550000"
-					).write.sync
+					).write.catch { |e| e }
 				end
-				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
-				Registration::Payment::InviteCode::REDIS.verify
+				assert_equal :test_result, result
+				assert_mock Command::COMMAND_MANAGER
+				assert_mock Registration::Payment::InviteCode::REDIS
 			end
 			em :test_write_bad_code_over_limit
 		end
 	end
 
 	class FinishTest < Minitest::Test
-		Registration::Finish::BLATHER = Minitest::Mock.new
 		Registration::Finish::REDIS = Minitest::Mock.new
 		BackendSgx::REDIS = Minitest::Mock.new
 
@@ -507,7 +525,6 @@ class RegistrationTest < Minitest::Test
 			iq = Blather::Stanza::Iq::Command.new
 			iq.from = "test\\40example.com@cheogram.com"
 			@finish = Registration::Finish.new(
-				iq,
 				Customer.new("test", sgx: @sgx),
 				"+15555550000"
 			)
@@ -547,17 +564,12 @@ class RegistrationTest < Minitest::Test
 					"Content-Type" => "application/json"
 				}
 			).to_return(status: 201)
-			@sgx.expect(
-				:register!,
-				EMPromise.resolve(OpenStruct.new(error?: false)),
-				["+15555550000"]
-			)
 			Registration::Finish::REDIS.expect(
 				:set,
 				nil,
 				[
 					"catapult_fwd-+15555550000",
-					"sip:test%5C40example.com%40cheogram.com@sip.cheogram.com"
+					"sip:test%40example.com@sip.cheogram.com"
 				]
 			)
 			BackendSgx::REDIS.expect(
@@ -565,7 +577,8 @@ class RegistrationTest < Minitest::Test
 				nil,
 				["catapult_fwd_timeout-customer_test@component", 25]
 			)
-			Registration::Finish::BLATHER.expect(
+			blather = Minitest::Mock.new
+			blather.expect(
 				:<<,
 				nil,
 				[Matching.new do |reply|
@@ -577,12 +590,20 @@ class RegistrationTest < Minitest::Test
 					)
 				end]
 			)
-			@finish.write.sync
+			execute_command(blather: blather) do
+				@sgx.expect(
+					:register!,
+					EMPromise.resolve(OpenStruct.new(error?: false)),
+					["+15555550000"]
+				)
+
+				@finish.write
+			end
 			assert_requested create_order
-			@sgx.verify
-			Registration::Finish::REDIS.verify
-			BackendSgx::REDIS.verify
-			Registration::Finish::BLATHER.verify
+			assert_mock @sgx
+			assert_mock Registration::Finish::REDIS
+			assert_mock BackendSgx::REDIS
+			assert_mock blather
 		end
 		em :test_write
 
@@ -605,7 +626,8 @@ class RegistrationTest < Minitest::Test
 					<OrderStatus>FAILED</OrderStatus>
 				</OrderResponse>
 			RESPONSE
-			Registration::Finish::BLATHER.expect(
+			blather = Minitest::Mock.new
+			blather.expect(
 				:<<,
 				nil,
 				[Matching.new do |reply|
@@ -618,9 +640,9 @@ class RegistrationTest < Minitest::Test
 					)
 				end]
 			)
-			@finish.write.sync
+			execute_command(blather: blather) { @finish.write }
 			assert_requested create_order
-			Registration::Finish::BLATHER.verify
+			assert_mock blather
 		end
 		em :test_write_tn_fail
 	end

test/test_web_register_manager.rb 🔗

@@ -15,18 +15,9 @@ class WebRegisterManagerTest < Minitest::Test
 	end
 
 	def test_choose_tel_have_tel
-		@manager["jid@example.com"] = "+15555550000"
-		iq = Blather::Stanza::Iq.new
-		iq.from = "jid@example.com"
-		assert_equal [iq, "+15555550000"], @manager.choose_tel(iq).sync
+		jid = "jid@example.com"
+		@manager[jid] = "+15555550000"
+		assert_equal "+15555550000", @manager[jid].choose_tel.sync
 	end
 	em :test_choose_tel_have_tel
-
-	def test_choose_tel_not_have_tel
-		skip "ChooseTel not implemented yet"
-		iq = Blather::Stanza::Iq.new
-		iq.from = "jid@example.com"
-		@manager.choose_tel(iq).sync
-	end
-	em :test_choose_tel_not_have_tel
 end