@@ -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
@@ -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