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