Merge branch 'porting'

Stephen Paul Weber created

* porting:
  Porting Script
  Porting Logic
  Add CommandExecution to BlatherNotify
  Import Blather Notify and ToForm from JMP Pay
  Allow User FWD to be Nil
  Move to New Blather Version with Better IDs
  Update ruby-bandwidth-iris Gem

Change summary

Gemfile                   |   4 
bin/porting               | 270 ++++++++++++++++++++++++++
forms/admin_info.rb       |   2 
lib/blather_client.rb     |   3 
lib/blather_notify.rb     | 208 ++++++++++++++++++++
lib/form_to_h.rb          |  12 +
lib/porting_step.rb       | 311 ++++++++++++++++++++++++++++++
test/test_porting_step.rb | 412 +++++++++++++++++++++++++++++++++++++++++
8 files changed, 1,218 insertions(+), 4 deletions(-)

Detailed changes

Gemfile 🔗

@@ -4,7 +4,7 @@ source "https://rubygems.org"
 
 gem "amazing_print"
 gem "bandwidth-sdk", "<= 6.1.0"
-gem "blather", git: "https://github.com/adhearsion/blather", branch: "develop"
+gem "blather", git: "https://github.com/psycotica0/blather", branch: "cv_new_id"
 gem "braintree"
 gem "dhall", ">= 0.5.3.fixed"
 gem "em-hiredis"
@@ -20,7 +20,7 @@ gem "multihashes"
 gem "ougai"
 gem "relative_time"
 gem "roda"
-gem "ruby-bandwidth-iris", git: "https://github.com/singpolyma/ruby-bandwidth-iris", branch: "tn_move"
+gem "ruby-bandwidth-iris"
 gem "sentry-ruby", "<= 4.3.1"
 gem "slim"
 gem "statsd-instrument", git: "https://github.com/singpolyma/statsd-instrument.git", branch: "graphite"

bin/porting 🔗

@@ -0,0 +1,270 @@
+#!/usr/bin/ruby
+# frozen_string_literal: true
+
+require "date"
+require "dhall"
+require "em-hiredis"
+require "em-http"
+require "em_promise"
+require "json"
+require "optparse"
+require "ruby-bandwidth-iris"
+require "securerandom"
+require "sentry-ruby"
+require "time"
+
+@verbosity = 0
+@real_data = true
+@dry_run = false
+
+OptionParser.new do |opts|
+	opts.banner = "Usage: porting [-vvf] DHALL_CONFIG"
+
+	opts.on(
+		"-v", "--verbose",
+		"Print to terminal, run twice to not even send to customer"
+	) do
+		@verbosity += 1
+	end
+
+	opts.on("-f", "--fake", "Run with fake ports rather than fetching") do
+		@real_data = false
+	end
+
+	opts.on(
+		"-n", "--dry-run",
+		"Figure out what state they're in, but don't take action"
+	) do
+		@dry_run = true
+	end
+
+	opts.on("-h", "--help", "Print this help") do
+		puts opts
+		exit
+	end
+end.parse!
+
+SCHEMA = "{
+	bandwidth : { account: Text, username: Text, password: Text },
+	xmpp: { jid: Text, password: Text },
+	notification: { endpoint: Text, source_number: Text },
+	pubsub: { server: Text, node: Text },
+	testing_tel: Text,
+	admin_server: Text
+}"
+
+raise "Need a Dhall config" unless ARGV[0]
+
+CONFIG = Dhall::Coder
+	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
+	.load("#{ARGV.first} : #{SCHEMA}", transform_keys: :to_sym)
+
+require_relative "../lib/blather_notify"
+require_relative "../lib/expiring_lock"
+require_relative "../lib/form_to_h"
+require_relative "../lib/porting_step"
+
+Faraday.default_adapter = :em_synchrony
+BandwidthIris::Client.global_options = {
+	account_id: CONFIG[:bandwidth][:account],
+	username: CONFIG[:bandwidth][:username],
+	password: CONFIG[:bandwidth][:password]
+}
+
+class FullManual < PortingStepRepo::Outputs
+	def info(id, key, msg)
+		puts "[#{id}] INFO(#{key}): #{msg}"
+	end
+
+	def warn(id, key, msg)
+		puts "[#{id}] WARN(#{key}): #{msg}"
+	end
+
+	def error(id, key, e_or_msg)
+		puts "[#{id}] ERRR(#{key}): #{e_or_msg}"
+		return unless e_or_msg.respond_to?(:backtrace)
+
+		e_or_msg.backtrace.each do |b|
+			puts "[#{id}] ERRR(#{key}): #{b}"
+		end
+	end
+
+	def to_customer(id, key, tel, msg)
+		puts "[#{id}] CUST(#{key}, #{tel}): #{msg}"
+	end
+end
+
+class ObservedAuto < FullManual
+	def initialize(endpoint, source_number)
+		@endpoint = endpoint
+		@src = source_number
+	end
+
+	def to_customer(id, key, tel, msg)
+		ExpiringLock.new(lock_key(id, key)).with do
+			EM::HttpRequest
+				.new(@endpoint)
+				.apost(
+					head: { "Content-Type" => "application/json" },
+					body: format_msg(tel, msg)
+				)
+		end
+	end
+
+protected
+
+	def lock_key(id, key)
+		"jmp_port_customer_msg_#{key}-#{id}"
+	end
+
+	def format_msg(tel, msg)
+		[{
+			time: DateTime.now.iso8601,
+			type: "message-received",
+			to: tel,
+			description: "Incoming message received",
+			message: actual_message(tel, msg)
+		}].to_json
+	end
+
+	def actual_message(tel, msg)
+		{
+			id: SecureRandom.uuid,
+			owner: tel,
+			applicationId: SecureRandom.uuid,
+			time: DateTime.now.iso8601,
+			segmentCount: 1,
+			direction: "in",
+			to: [tel], from: @src,
+			text: msg
+		}
+	end
+end
+
+class FullAutomatic < ObservedAuto
+	using FormToH
+
+	def initialize(pubsub_addr, endpoint, source_number)
+		@pubsub = BlatherNotify.pubsub(pubsub_addr)
+
+		Sentry.init do |config|
+			config.background_worker_threads = 0
+		end
+
+		super(endpoint, source_number)
+	end
+
+	# No one's watch; swallow informational messages
+	def info(*); end
+
+	def warn(id, key, msg)
+		ExpiringLock.new(warn_lock_key(id, key), expiry: 60 * 15).with do
+			entrykey = "#{id}:#{key}"
+			@pubsub.publish("#{entrykey}": error_entry("Port Warning", msg, entrykey))
+		end
+	end
+
+	def error(id, key, e_or_msg)
+		Sentry.with_scope do |scope|
+			scope.set_context("port", { id: id, action: key })
+
+			if e_or_msg.is_a?(::Exception)
+				Sentry.capture_exception(e_or_msg)
+			else
+				Sentry.capture_message(e_or_msg.to_s)
+			end
+		end
+	end
+
+protected
+
+	def error_entry(title, text, id)
+		Nokogiri::XML::Builder.new { |xml|
+			xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
+				xml.updated DateTime.now.iso8601
+				xml.id id
+				xml.title title
+				xml.content text.to_s, type: "text"
+				xml.author { xml.name "porting" }
+				xml.generator "porting", version: "1.0"
+			end
+		}.doc.root
+	end
+
+	def warn_lock_key(id, key)
+		"jmp_port_warn_msg_#{key}-#{id}"
+	end
+end
+
+@output =
+	case @verbosity
+	when 0
+		FullAutomatic.new(
+			BlatherNotify::PubSub::Address.new(**CONFIG[:pubsub]),
+			CONFIG[:notification][:endpoint],
+			CONFIG[:notification][:source_number]
+		)
+	when 1
+		ObservedAuto.new(
+			CONFIG[:notification][:endpoint],
+			CONFIG[:notification][:source_number]
+		)
+	else
+		FullManual.new
+	end
+
+ports = if @real_data
+	BandwidthIris::PortIn.list(
+		page: 1,
+		size: 50,
+		start_date: Date.today - 1,
+		end_date: Date.today
+	) || []
+else
+	MP = Struct.new(
+		:order_id,
+		:processing_status,
+		:actual_foc_date,
+		:last_modified_date,
+		:customer_order_id,
+		:billing_telephone_number
+	)
+
+	minutes = 1.0 / (24 * 60)
+
+	[
+		# This should be ignored
+		MP.new("T01", "SUBMITTED", nil, DateTime.now - 1, "ignored", "9998887777"),
+		MP.new(
+			"T02", "COMPLETE", DateTime.now - 60 * minutes,
+			DateTime.now - 55 * minutes, "0001", "2223334444"
+		)
+	]
+end
+
+EM.run do
+	REDIS = EM::Hiredis.connect
+
+	BlatherNotify.start(
+		CONFIG[:xmpp][:jid],
+		CONFIG[:xmpp][:password]
+	).then {
+		ports.reduce(EMPromise.resolve(nil)) { |promise, port|
+			promise.then do
+				@output.info(port.order_id, :start, "Here we go")
+				PortingStepRepo.new(output: @output).find(port).then { |s|
+					@output.info(port.order_id, :class, s.class)
+					s
+				}.then { |s|
+					if @dry_run
+						@output.info("DRY", :dry, "Not taking action")
+					else
+						s.perform_next_step
+					end
+				}
+			end
+		}
+	}.catch { |e|
+		@output.error("ROOT", :catch, e)
+	}.then { BlatherNotify.shutdown }
+end

forms/admin_info.rb 🔗

@@ -31,7 +31,7 @@ if @admin_info.info.tel
 	)
 end
 
-if @admin_info.fwd.uri
+if @admin_info.fwd&.uri
 	field(
 		var: "fwd",
 		label: "Fwd",

lib/blather_client.rb 🔗

@@ -59,7 +59,8 @@ class BlatherClient < Blather::Client
 			found = stanza.find(*guards)
 			handler.call(stanza, found) unless found.empty?
 		else
-			handler.call(stanza) unless guarded?(guards, stanza)
+			throw :pass if guarded?(guards, stanza)
+			handler.call(stanza)
 		end
 
 		return result unless result.is_a?(Promise)

lib/blather_notify.rb 🔗

@@ -0,0 +1,208 @@
+# frozen_string_literal: true
+
+require "blather/client/dsl"
+require "em_promise"
+require "timeout"
+
+require "ostruct"
+require "securerandom"
+require_relative "form_to_h"
+
+module BlatherNotify
+	extend Blather::DSL
+
+	class PubSub
+		class Address
+			attr_reader :node, :server
+
+			def initialize(node:, server:)
+				@node = node
+				@server = server
+			end
+
+			def to_uri
+				"xmpp:#{@server}?;node=#{@node}"
+			end
+		end
+
+		def initialize(blather, addr)
+			@blather = blather
+			@addr = addr
+		end
+
+		def publish(xml)
+			@blather.write_with_promise(
+				Blather::Stanza::PubSub::Publish.new(
+					@addr.server,
+					@addr.node,
+					:set,
+					xml
+				)
+			)
+		end
+	end
+
+	class CommandExecution
+		using FormToH
+
+		class FormErrorResponse < RuntimeError; end
+
+		def initialize(blather, server, node)
+			@blather = blather
+			@server = server
+			@node = node
+			@sessionid = nil
+		end
+
+		def fetch_and_submit(**form)
+			get_form.then { |response|
+				@sessionid ||= response.sessionid
+
+				validate_form(form, response.form)
+
+				@blather.write_with_promise(@blather.command(
+					@node, @sessionid, form: form.to_form(:submit), server: @server
+				))
+			}.then(&method(:check_for_error)).then { |response|
+				@last_response = response
+				OpenStruct.new(response.form.to_h)
+			}
+		end
+
+	protected
+
+		def check_for_error(response)
+			if response.note&.[]("type") == "error"
+				raise FormErrorResponse, response.note.text
+			end
+
+			response
+		end
+
+		def validate_form(to_submit, received)
+			to_submit.each_key do |key|
+				raise "No field #{key}" unless received.field(key.to_s)
+			end
+		end
+
+		def get_form
+			# If we already got a form on the last submit then
+			# assume we should fill that out here.
+			# If not, then move next to find the form
+			if @last_response&.form&.form?
+				EMPromise.resolve(@last_response)
+			else
+				@blather.write_with_promise(@blather.command(
+					@node,
+					@sessionid,
+					server: @server
+				))
+			end
+		end
+	end
+
+	@ready = Queue.new
+
+	when_ready { @ready << :ready }
+
+	def self.start(jid, password, default_pubsub_addr: nil)
+		# workqueue_count MUST be 0 or else Blather uses threads!
+		setup(jid, password, nil, nil, nil, nil, workqueue_count: 0)
+		set_default_pubsub(default_pubsub_addr)
+
+		EM.error_handler(&method(:panic))
+
+		EM.next_tick { client.run }
+
+		block_until_ready
+	end
+
+	def self.block_until_ready
+		if EM.reactor_running?
+			promise = EMPromise.new
+			disconnected { true.tap { EM.next_tick { EM.stop } } }
+			Thread.new { promise.fulfill(@ready.pop) }
+			timeout_promise(promise, timeout: 30)
+		else
+			@thread = Thread.new { EM.run }
+			Timeout.timeout(30) { @ready.pop }
+			at_exit { wait_then_exit }
+		end
+	end
+
+	def self.panic(e)
+		warn e.message
+		warn e.backtrace
+		exit! 2
+	end
+
+	def self.wait_then_exit
+		disconnected { EM.stop }
+		EM.add_timer(30) { EM.stop }
+		shutdown
+		@thread&.join
+	end
+
+	def self.timeout_promise(promise, timeout: 15)
+		timer = EventMachine::Timer.new(timeout) {
+			promise.reject(:timeout)
+		}
+
+		promise.then do
+			timer.cancel
+		end
+	end
+
+	def self.write_with_promise(stanza)
+		promise = EMPromise.new
+		timeout_promise(promise)
+
+		client.write_with_handler(stanza) do |s|
+			if s.error? || s.type == :error
+				promise.reject(s)
+			else
+				promise.fulfill(s)
+			end
+		end
+		promise
+	end
+
+	def self.command(
+		node, sessionid=nil,
+		server: CONFIG[:sgx_jmp], action: :execute, form: nil
+	)
+		Blather::Stanza::Iq::Command.new.tap do |cmd|
+			cmd.to = server
+			cmd.node = node
+			cmd.command[:sessionid] = sessionid if sessionid
+			cmd.action = action
+			cmd.command << form if form
+		end
+	end
+
+	def self.execute(command_node, form=nil)
+		write_with_promise(command(command_node)).then do |iq|
+			next iq unless form
+
+			write_with_promise(command(command_node, iq.sessionid, form: form))
+		end
+	end
+
+	def self.pubsub(addr)
+		PubSub.new(self, addr)
+	end
+
+	def self.set_default_pubsub(addr)
+		@default_pubsub = addr && pubsub(addr)
+	end
+
+	def self.publish(xml)
+		raise "No default pubsub set!" unless @default_pubsub
+
+		@default_pubsub.publish(xml)
+	end
+
+	def self.command_execution(server, node)
+		CommandExecution.new(self, server, node)
+	end
+end

lib/form_to_h.rb 🔗

@@ -18,4 +18,16 @@ module FormToH
 			params.to_h
 		end
 	end
+
+	refine ::Hash do
+		def to_fields
+			map { |k, v| { var: k.to_s, value: v.to_s } }
+		end
+
+		def to_form(type)
+			Blather::Stanza::Iq::X.new(type).tap do |form|
+				form.fields = to_fields
+			end
+		end
+	end
 end

lib/porting_step.rb 🔗

@@ -0,0 +1,311 @@
+# frozen_string_literal: true
+
+require "date"
+require "em_promise"
+require "lazy_object"
+require "value_semantics/monkey_patched"
+
+require_relative "blather_notify"
+
+class Tel
+	def initialize(str)
+		@tel = if str.is_a? Tel
+			str.to_s
+		else
+			"+1#{str.sub(/^\+?1?/, '')}"
+		end
+	end
+
+	def to_s
+		@tel
+	end
+
+	def ==(other)
+		to_s == other.to_s
+	end
+end
+
+class PortingStepRepo
+	# Any thing that debounces messages must happen inside this.
+	# The porting logic will pass everything in every time.
+	class Outputs
+		def info(port_id, kind, msg); end
+
+		def warn(port_id, kind, msg); end
+
+		def error(port_id, kind, e_or_msg); end
+
+		def to_customer(port_id, kind, tel, msg); end
+	end
+
+	value_semantics do
+		redis             Anything(), default: LazyObject.new { REDIS }
+		blather_notify    Anything(), default: BlatherNotify
+		admin_server      Anything(), default: CONFIG[:admin_server]
+		testing_tel       Anything(), default: CONFIG[:testing_tel]
+		output            Outputs, default: Outputs.new
+	end
+
+	def find(port)
+		if_processing(port) do
+			case port.processing_status
+			when "FOC"
+				FOC.new(**to_h).find(port)
+			when "COMPLETE"
+				Complete.new(**to_h).find(port)
+			else
+				Wait.new(port, output: output)
+			end
+		end
+	end
+
+	def if_processing(port)
+		redis.exists("jmp_port_freeze-#{port.order_id}").then do |v|
+			next Frozen.new(port, output: output) if v == 1
+
+			yield
+		end
+	end
+
+	class FOC < self
+		# This is how long we'll wait for a port to move from FOC to COMPLETED
+		# It's in fractional days because DateTime
+		GRACE_PERIOD = 15.0 / (24 * 60)
+
+		def find(port)
+			Alert.for(
+				port,
+				grace_period: GRACE_PERIOD,
+				output: output, key: :late_foc,
+				msg: "⚠ Port is still in FOC state a while past FOC",
+				real_step: Wait.new(port, output: output)
+			)
+		end
+	end
+
+	class Complete < self
+		# If it's been 35 minutes and the number isn't reachable, a human
+		# should get involved
+		GRACE_PERIOD = 35.0 / (24 * 60)
+
+		def find(port)
+			if_not_complete(port) do
+				exe = blather_notify.command_execution(admin_server, "customer info")
+				AdminCommand.new(exe: exe, **to_h).find(port).then do |step|
+					Alert.for(
+						port,
+						grace_period: GRACE_PERIOD, output: output,
+						key: :late_finish, msg: msg(port), real_step: step
+					)
+				end
+			end
+		end
+
+		def if_not_complete(port)
+			redis.exists("jmp_port_complete-#{port.order_id}").then do |v|
+				next Done.new(port, output: output) if v == 1
+
+				yield
+			end
+		end
+
+		def msg(port)
+			"⚠ Port still hasn't finished. We'll keep trying unless you set redis "\
+				"key `jmp_port_freeze-#{port.order_id}`"
+		end
+
+		class AdminCommand < self
+			def initialize(exe:, **kwargs)
+				@exe = exe
+				super(**kwargs)
+			end
+
+			def to_h
+				super.merge(exe: @exe)
+			end
+
+			def find(port)
+				@exe.fetch_and_submit(q: port.customer_order_id).then do |form|
+					tel = Tel.new(port.billing_telephone_number)
+					if tel == Tel.new(form.tel)
+						GoodNumber.new(**to_h).find(port)
+					else
+						WrongNumber.new(right_number: tel, execution: @exe)
+					end
+				end
+			end
+
+			class GoodNumber < self
+				def find(port)
+					Reachability.new(type: "voice", **to_h).test(port) do
+						Reachability.new(type: "sms", **to_h).test(port) do
+							FinishUp.new(port, redis: redis, output: output)
+						end
+					end
+				end
+
+				class Reachability < self
+					def initialize(type:, **args)
+						@type = type
+						super(**args)
+					end
+
+					def test(port)
+						execute_command(port).then do |response|
+							if response.count == "0"
+								RunTest.new(
+									type: @type, tel: port.billing_telephone_number,
+									**to_h.slice(:blather_notify, :testing_tel, :admin_server)
+								)
+							else
+								yield
+							end
+						end
+					end
+
+					class RunTest
+						# This is here for tests
+						attr_reader :type
+
+						def initialize(
+							type:, tel:, blather_notify:, testing_tel:, admin_server:
+						)
+							@type = type
+							@tel = tel
+							@blather_notify = blather_notify
+							@testing_tel = testing_tel
+							@admin_server = admin_server
+						end
+
+						def perform_next_step
+							@blather_notify.command_execution(@admin_server, "reachability")
+								.fetch_and_submit(
+									tel: @tel, type: @type, reachability_tel: @testing_tel
+								)
+						end
+					end
+
+				protected
+
+					def execute_command(port)
+						blather_notify.command_execution(admin_server, "reachability")
+							.fetch_and_submit(tel: port.billing_telephone_number, type: @type)
+					end
+				end
+
+				class FinishUp
+					MESSAGE = "Hi!  This is JMP support - your number has "\
+					"successfully transferred in to JMP!  All calls/messages "\
+					"will now use your transferred-in number - your old JMP "\
+					"number has been disabled.  Let us know if you have any "\
+					"questions and thanks for using JMP!"
+
+					def initialize(port, redis:, output:)
+						@port = port
+						@redis = redis
+						@output = output
+					end
+
+					def set_key
+						@redis.set(
+							"jmp_port_complete-#{@port.order_id}",
+							DateTime.now.iso8601,
+							"EX",
+							60 * 60 * 24 * 2 ### 2 Days should be enough to not see it listed
+						)
+					end
+
+					def perform_next_step
+						set_key.then do
+							EMPromise.all([
+								@output.info(@port.order_id, :complete, "Port Complete!"),
+								@output.to_customer(
+									@port.order_id, :complete,
+									Tel.new(@port.billing_telephone_number), MESSAGE
+								)
+							])
+						end
+					end
+				end
+			end
+
+			class WrongNumber
+				def initialize(right_number:, execution:)
+					@right_number = right_number
+					@exe = execution
+				end
+
+				def perform_next_step
+					@exe.fetch_and_submit(action: "number_change").then do |_form|
+						@exe.fetch_and_submit(new_tel: @right_number, should_delete: "true")
+					end
+				end
+			end
+		end
+	end
+
+	# This doesn't do anything and just waits for something to happen later
+	class Wait
+		def initialize(port, output:)
+			@port = port
+			@output = output
+		end
+
+		def perform_next_step
+			@output.info(@port.order_id, :wait, "Waiting...")
+		end
+	end
+
+	# This also doesn't do anything but is more content about it
+	class Done
+		def initialize(port, output:)
+			@port = port
+			@output = output
+		end
+
+		def perform_next_step
+			@output.info(@port.order_id, :done, "Done.")
+		end
+	end
+
+	# This also also doesn't do anything but is intentional
+	class Frozen
+		def initialize(port, output:)
+			@port = port
+			@output = output
+		end
+
+		def perform_next_step
+			@output.info(@port.order_id, :frozen, "Frozen.")
+		end
+	end
+
+	# This class sends and error to the human to check things out
+	class Alert
+		def self.for(port, grace_period:, real_step:, **args)
+			if (DateTime.now - port.actual_foc_date) > grace_period
+				new(port, real_step: real_step, **args)
+			else
+				real_step
+			end
+		end
+
+		# For tests
+		attr_reader :key
+		attr_reader :real_step
+
+		def initialize(port, real_step:, output:, msg:, key:)
+			@port = port
+			@real_step = real_step
+			@output = output
+			@msg = msg
+			@key = key
+		end
+
+		def perform_next_step
+			@output.warn(@port.order_id, @key, @msg).then {
+				@real_step.perform_next_step
+			}
+		end
+	end
+end

test/test_porting_step.rb 🔗

@@ -0,0 +1,412 @@
+# frozen_string_literal: true
+
+require "date"
+require "ostruct"
+require "test_helper"
+
+require "customer_info"
+require "form_template"
+require "form_to_h"
+require "porting_step"
+
+MINS = 1.0 / (24 * 60)
+
+class BlatherNotifyMock < Minitest::Mock
+	def initialize
+		super
+		@exes = []
+	end
+
+	def expect_execution(server, node, *args)
+		exe = Execution.new(args)
+		expect(
+			:command_execution,
+			exe,
+			[server, node]
+		)
+		@exes << exe
+	end
+
+	def verify
+		super
+		@exes.each(&:verify)
+	end
+
+	class Execution < Minitest::Mock
+		def initialize(args)
+			super()
+			args.each_slice(2) do |(submission, result)|
+				expect(
+					:fetch_and_submit,
+					EMPromise.resolve(to_response(result)),
+					**submission
+				)
+			end
+		end
+
+		using FormToH
+
+		def to_response(form)
+			OpenStruct.new(form.to_h)
+		end
+	end
+end
+
+def info(tel)
+	CustomerInfo.new(
+		plan_info: PlanInfo::NoPlan.new,
+		tel: tel,
+		balance: 0.to_d,
+		cnam: nil
+	)
+end
+
+def admin_info(customer_id, tel)
+	AdminInfo.new(
+		jid: Blather::JID.new("#{customer_id}@example.com"),
+		customer_id: customer_id,
+		fwd: nil,
+		info: info(tel),
+		api: API::V2.new,
+		call_info: "",
+		trust_level: "",
+		backend_jid: "customer_#{customer_id}@example.com"
+	)
+end
+
+def menu
+	FormTemplate.render("admin_menu")
+end
+
+class PortingStepTest < Minitest::Test
+	Port = Struct.new(
+		:order_id,
+		:processing_status,
+		:actual_foc_date,
+		:last_modified_date,
+		:customer_order_id,
+		:billing_telephone_number
+	)
+
+	def test_ignore_submitted_ports
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+
+		step = PortingStepRepo.new(redis: redis).find(Port.new(
+			"01",
+			"SUBMITTED",
+			nil,
+			DateTime.now - 1 * MINS,
+			"ignored",
+			"9998887777"
+		)).sync
+
+		assert_kind_of PortingStepRepo::Wait, step
+	end
+	em :test_ignore_submitted_ports
+
+	def test_ignore_recent_foc
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+
+		step = PortingStepRepo.new(redis: redis).find(Port.new(
+			"01",
+			"FOC",
+			DateTime.now - 5 * MINS,
+			DateTime.now - 1 * MINS,
+			"ignored",
+			"9998887777"
+		)).sync
+
+		assert_kind_of PortingStepRepo::Wait, step
+	end
+	em :test_ignore_recent_foc
+
+	def test_warn_for_late_foc
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+
+		step = PortingStepRepo.new(redis: redis).find(Port.new(
+			"01",
+			"FOC",
+			DateTime.now - 25 * MINS,
+			DateTime.now - 1 * MINS,
+			"ignored",
+			"9998887777"
+		)).sync
+
+		assert_kind_of PortingStepRepo::Alert, step
+		assert_equal :late_foc, step.key
+		assert_kind_of PortingStepRepo::Wait, step.real_step
+	end
+	em :test_warn_for_late_foc
+
+	def test_already_complete
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+		redis.expect(:exists, 1, ["jmp_port_complete-01"])
+
+		step = PortingStepRepo.new(redis: redis).find(Port.new(
+			"01",
+			"COMPLETE",
+			DateTime.now - 25 * MINS,
+			DateTime.now - 1 * MINS,
+			"completed",
+			"9998887777"
+		)).sync
+
+		assert_kind_of PortingStepRepo::Done, step
+		assert_mock redis
+	end
+	em :test_already_complete
+
+	def test_change_number
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+		redis.expect(:exists, "0", ["jmp_port_complete-01"])
+
+		notify = BlatherNotifyMock.new
+		notify.expect_execution(
+			"sgx", "customer info",
+			{ q: "starting" }, admin_info("starting", "+19998881111").form
+		)
+
+		step = PortingStepRepo.new(
+			redis: redis,
+			blather_notify: notify,
+			admin_server: "sgx"
+		).find(Port.new(
+			"01",
+			"COMPLETE",
+			DateTime.now - 25 * MINS,
+			DateTime.now - 1 * MINS,
+			"starting",
+			"9998887777"
+		)).sync
+
+		assert_kind_of PortingStepRepo::Complete::AdminCommand::WrongNumber, step
+		assert_mock redis
+		assert_mock notify
+	end
+	em :test_change_number
+
+	def test_first_reachability
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+		redis.expect(:exists, "0", ["jmp_port_complete-01"])
+
+		notify = BlatherNotifyMock.new
+		notify.expect_execution(
+			"sgx", "customer info",
+			{ q: "starting" }, admin_info("starting", "+19998887777").form
+		)
+
+		notify.expect_execution(
+			"sgx", "reachability",
+			{ tel: "9998887777", type: "voice" },
+			FormTemplate.render("reachability_result", count: 0)
+		)
+
+		step = PortingStepRepo.new(
+			redis: redis,
+			blather_notify: notify,
+			admin_server: "sgx"
+		).find(Port.new(
+			"01",
+			"COMPLETE",
+			DateTime.now - 25 * MINS,
+			DateTime.now - 1 * MINS,
+			"starting",
+			"9998887777"
+		)).sync
+
+		assert_kind_of(
+			PortingStepRepo::Complete::AdminCommand::GoodNumber::
+				Reachability::RunTest,
+			step
+		)
+		assert_equal "voice", step.type
+		assert_mock redis
+		assert_mock notify
+	end
+	em :test_first_reachability
+
+	def test_reach_sms_reachability
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+		redis.expect(:exists, "0", ["jmp_port_complete-01"])
+
+		notify = BlatherNotifyMock.new
+		notify.expect_execution(
+			"sgx", "customer info",
+			{ q: "starting" }, admin_info("starting", "+19998887777").form
+		)
+
+		notify.expect_execution(
+			"sgx", "reachability",
+			{ tel: "9998887777", type: "voice" },
+			FormTemplate.render("reachability_result", count: 1)
+		)
+
+		notify.expect_execution(
+			"sgx", "reachability",
+			{ tel: "9998887777", type: "sms" },
+			FormTemplate.render("reachability_result", count: 0)
+		)
+
+		step = PortingStepRepo.new(
+			redis: redis,
+			blather_notify: notify,
+			admin_server: "sgx"
+		).find(Port.new(
+			"01",
+			"COMPLETE",
+			DateTime.now - 25 * MINS,
+			DateTime.now - 1 * MINS,
+			"starting",
+			"9998887777"
+		)).sync
+
+		assert_kind_of(
+			PortingStepRepo::Complete::AdminCommand::GoodNumber::
+				Reachability::RunTest,
+			step
+		)
+		assert_equal "sms", step.type
+		assert_mock redis
+		assert_mock notify
+	end
+	em :test_reach_sms_reachability
+
+	def test_all_reachable
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+		redis.expect(:exists, "0", ["jmp_port_complete-01"])
+
+		notify = BlatherNotifyMock.new
+		notify.expect_execution(
+			"sgx", "customer info",
+			{ q: "starting" }, admin_info("starting", "+19998887777").form
+		)
+
+		notify.expect_execution(
+			"sgx", "reachability",
+			{ tel: "9998887777", type: "voice" },
+			FormTemplate.render("reachability_result", count: 1)
+		)
+
+		notify.expect_execution(
+			"sgx", "reachability",
+			{ tel: "9998887777", type: "sms" },
+			FormTemplate.render("reachability_result", count: 1)
+		)
+
+		step = PortingStepRepo.new(
+			redis: redis,
+			blather_notify: notify,
+			admin_server: "sgx"
+		).find(Port.new(
+			"01",
+			"COMPLETE",
+			DateTime.now - 25 * MINS,
+			DateTime.now - 1 * MINS,
+			"starting",
+			"9998887777"
+		)).sync
+
+		assert_kind_of(
+			PortingStepRepo::Complete::AdminCommand::GoodNumber::FinishUp,
+			step
+		)
+		assert_mock redis
+		assert_mock notify
+	end
+	em :test_all_reachable
+
+	def test_not_done_in_time
+		redis = Minitest::Mock.new
+		redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"])
+		redis.expect(:exists, "0", ["jmp_port_complete-01"])
+
+		notify = BlatherNotifyMock.new
+		notify.expect_execution(
+			"sgx", "customer info",
+			{ q: "starting" }, admin_info("starting", "+19998887777").form
+		)
+
+		notify.expect_execution(
+			"sgx", "reachability",
+			{ tel: "9998887777", type: "voice" },
+			FormTemplate.render("reachability_result", count: 0)
+		)
+
+		step = PortingStepRepo.new(
+			redis: redis,
+			blather_notify: notify,
+			admin_server: "sgx"
+		).find(Port.new(
+			"01",
+			"COMPLETE",
+			DateTime.now - 55 * MINS,
+			DateTime.now - 50 * MINS,
+			"starting",
+			"9998887777"
+		)).sync
+
+		assert_kind_of PortingStepRepo::Alert, step
+		assert_equal :late_finish, step.key
+		assert_kind_of(
+			PortingStepRepo::Complete::AdminCommand::GoodNumber::
+				Reachability::RunTest,
+			step.real_step
+		)
+		assert_mock redis
+		assert_mock notify
+	end
+	em :test_not_done_in_time
+
+	def test_ignore_frozen_ports
+		# This tests that we ignore ports in various states
+		[
+			Port.new(
+				"01",
+				"SUBMITTED",
+				nil,
+				DateTime.now - 1 * MINS,
+				"ignored",
+				"9998887777"
+			),
+			Port.new(
+				"01",
+				"FOC",
+				DateTime.now - 300 * MINS,
+				DateTime.now - 300 * MINS,
+				"ignored",
+				"9998887777"
+			),
+			Port.new(
+				"01",
+				"COMPLETED",
+				DateTime.now - 10 * MINS,
+				DateTime.now - 10 * MINS,
+				"ignored",
+				"9998887777"
+			),
+			Port.new(
+				"01",
+				"COMPLETED",
+				DateTime.now - 300 * MINS,
+				DateTime.now - 300 * MINS,
+				"ignored",
+				"9998887777"
+			)
+		].each do |port|
+			redis = Minitest::Mock.new
+			redis.expect(:exists, EMPromise.resolve(1), ["jmp_port_freeze-01"])
+
+			step = PortingStepRepo.new(redis: redis).find(port).sync
+			assert_kind_of PortingStepRepo::Frozen, step
+		end
+	end
+	em :test_ignore_frozen_ports
+end