Refactor commands to have Command and Command::Execution objects

Stephen Paul Weber created

Brings the common elements of all commands together, and threads the most useful
state (such as ability to reply) through automatically using the new EMPromise
fiber trampoline.

Change summary

.rubocop.yml                      |   4 
Gemfile                           |   4 
lib/bandwidth_tn_order.rb         |   4 
lib/command.rb                    | 146 ++++++++++
lib/command_list.rb               |  73 +---
lib/polyfill.rb                   |   5 
lib/registration.rb               | 217 +++++++--------
lib/web_register_manager.rb       |  20 
sgx_jmp.rb                        | 251 +++++++-----------
test/test_command_list.rb         |  47 ++
test/test_helper.rb               |  13 
test/test_registration.rb         | 448 +++++++++++++++++---------------
test/test_web_register_manager.rb |  15 
13 files changed, 672 insertions(+), 575 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,146 @@
+# 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
+		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
+			raise ErrorToSend, 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(ErrorToSend) { |e|
+				@blather << e.stanza
+			}.catch do |e|
+				log_error(e)
+				finish(@format_error.call(e), type: :error)
+			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/polyfill.rb 🔗

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

lib/registration.rb 🔗

@@ -5,6 +5,7 @@ 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"
@@ -12,54 +13,44 @@ 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 +121,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 +153,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 +201,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 +217,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 +243,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 +276,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 +302,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 +326,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 +377,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 +393,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 +416,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 +433,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 +445,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 🔗

@@ -15,29 +15,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 🔗

@@ -36,12 +36,14 @@ 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"
@@ -113,7 +115,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
@@ -370,174 +375,120 @@ 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") }
+}.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