From 53115475d89aaa444e95231c949384f1a1b134e9 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Fri, 17 Mar 2023 16:34:11 -0400 Subject: [PATCH] Porting Logic Ok, so I've built a big tree of states here to represent us trying to figure out what the current situation is in this mad world. Then each one of those leaves has some thing that it does to hopefully move towards the next step. There's also the warnings which are emitted, while also still attempting to make progress. We want to tell people about things, but not get stuck in "busted" mode forever. --- lib/porting_step.rb | 311 ++++++++++++++++++++++++++++ test/test_porting_step.rb | 412 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 723 insertions(+) create mode 100644 lib/porting_step.rb create mode 100644 test/test_porting_step.rb diff --git a/lib/porting_step.rb b/lib/porting_step.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d3e93779f1f68ff83c3e5f31d9bcbdf1e7419f7 --- /dev/null +++ b/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 diff --git a/test/test_porting_step.rb b/test/test_porting_step.rb new file mode 100644 index 0000000000000000000000000000000000000000..940110d94b6a41d1be2d370703bc1c27d1c14afe --- /dev/null +++ b/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