expose editing esim nicks

Phillip Davis created

name can be set at registration and bulk edited later as well

Change summary

.rubocop.yml                |   2 
forms/edit_sim_nicknames.rb |  14 ++
forms/sim_details.rb        |   3 
lib/edit_sim_nicknames.rb   |  30 ++++
lib/sim.rb                  |   2 
lib/sim_order.rb            |  74 +++++++++--
lib/sim_repo.rb             |  11 +
lib/utils.rb                |  17 ++
sgx_jmp.rb                  |  10 +
test/test_sim_order.rb      | 237 +++++++++++++++++++++++++++++++++++++++
10 files changed, 380 insertions(+), 20 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -11,6 +11,8 @@ Metrics/ClassLength:
 Metrics/MethodLength:
   Exclude:
     - test/*
+  CountAsOne:
+    - array
 
 Metrics/BlockLength:
   ExcludedMethods:

forms/edit_sim_nicknames.rb 🔗

@@ -0,0 +1,14 @@
+form!
+
+if @sims.empty?
+	instructions "You have no (e)SIMs."
+else
+	@sims.map { |sim|
+		field(
+			var: "sim-#{sim.iccid}",
+			label: sim.iccid,
+			type: "text-single",
+			value: sim.nickname
+		)
+	}
+end

forms/sim_details.rb 🔗

@@ -19,7 +19,8 @@ field(
 	options: [
 		{ label: "Done", value: "complete" },
 		{ label: "Order new Physical SIM", value: "order-sim" },
-		{ label: "Order new eSIM", value: "order-esim" }
+		{ label: "Order new eSIM", value: "order-esim" },
+		{ label: "Edit (e)SIM nicknames", value: "edit-nicknames" }
 	],
 	value: "complete"
 )

lib/edit_sim_nicknames.rb 🔗

@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class EditSimNicknames
+	# @param [Customer] customer the customer editing the sims
+	# @param [Array<Sim>] sims the sims whose nicknames
+	# 					  are up for editing
+	def initialize(customer, sims, sim_repo: SIMRepo.new(DB))
+		@customer = customer
+		@sims = sims
+		@sim_repo = sim_repo
+	end
+
+	def form
+		FormTemplate.render(
+			"edit_sim_nicknames",
+			sims: @sims
+		)
+	end
+
+	def complete(iq)
+		EMPromise.resolve(nil).then {
+			sims = @sims.map { |sim|
+				sim.with(nickname: iq.form.field("sim-#{sim.iccid}").value)
+			}
+			@sim_repo.update_nicknames(sims)
+		}.then {
+			Command.finish("Successfully updated SIM nicknames.")
+		}
+	end
+end

lib/sim.rb 🔗

@@ -15,7 +15,7 @@ class SIM
 	def self.extract(kwargs)
 		kwargs = kwargs&.transform_keys(&:to_sym) || {}
 		new(kwargs.slice(
-			:iccid, :lpa_code, :remaining_usage_kb, :remaining_days, :notes
+			:iccid, :lpa_code, :remaining_usage_kb, :remaining_days, :notes, :nickname
 		))
 	end
 

lib/sim_order.rb 🔗

@@ -30,6 +30,12 @@ class SIMOrder
 				label: "Shipping Address",
 				var: "addr",
 				required: true
+			},
+			{
+				type: "text-single",
+				var: "nickname",
+				label: "Nickname",
+				required: false
 			}
 		]
 	end
@@ -52,11 +58,29 @@ class SIMOrder
 	end
 
 	def complete(iq)
-		addr = Array(iq.form.field("addr").value).join("\n")
-		EMPromise.resolve(nil).then { commit }.then do |sim|
+		form = iq.form
+		EMPromise.resolve(nil).then {
+			commit(form.field("nickname").value.presence || self.class.label)
+		}.then do |sim|
+			Ack.new(
+				@customer,
+				sim,
+				Array(form.field("addr").value).join("\n")
+			).complete
+		end
+	end
+
+	class Ack
+		def initialize(customer, sim, addr)
+			@customer = customer
+			@sim = sim
+			@addr = addr
+		end
+
+		def complete
 			@customer.stanza_from(Blather::Stanza::Message.new(
 				Blather::JID.new(""), # Doesn't matter, sgx is set to direct target
-				"SIM ORDER: #{sim.iccid}\n#{addr}"
+				"SIM ORDER: #{@sim.iccid}\n#{@addr}"
 			))
 			Command.finish(
 				"You will receive an notice from support when your SIM ships."
@@ -66,10 +90,12 @@ class SIMOrder
 
 protected
 
-	def commit
+	# @param [String, nil] nickname the nickname, if any, assigned to
+	# 					   the sim by the customer
+	def commit(nickname)
 		DB.transaction do
 			sim = @sim_repo.available.sync
-			@sim_repo.put_owner(sim, @customer, self.class.label)
+			@sim_repo.put_owner(sim, @customer, nickname)
 			keepgo_tx = @sim_repo.refill(sim, amount_mb: 1024).sync
 			raise "SIM activation failed" unless keepgo_tx["ack"] == "success"
 
@@ -96,16 +122,34 @@ protected
 			[]
 		end
 
-		def complete(_)
-			EMPromise.resolve(nil).then { commit }.then do |sim|
-				Command.finish do |reply|
-					oob = OOB.find_or_create(reply.command)
-					oob.url = sim.lpa_code
-					oob.desc = "LPA Activation Code"
-					reply.command << FormTemplate.render(
-						"order_sim/esim_complete", sim: sim
-					)
-				end
+		# @param [Blather::Stanza::Iq] iq the stanza
+		# 		  containing a filled out `order_sim/with_balance`
+		def complete(iq)
+			EMPromise.resolve(nil).then {
+				commit(
+					nickname: iq.form.field("nickname").value.presence || self.class.label
+				)
+			}.then do |sim|
+				ActivationCode.new(sim).complete
+			end
+		end
+	end
+
+	class ActivationCode
+		# @param [Sim] sim the sim which the customer
+		# 			   just ordered
+		def initialize(sim)
+			@sim = sim
+		end
+
+		def complete
+			Command.finish do |reply|
+				oob = OOB.find_or_create(reply.command)
+				oob.url = @sim.lpa_code
+				oob.desc = "LPA Activation Code"
+				reply.command << FormTemplate.render(
+					"order_sim/esim_complete", sim: @sim
+				)
 			end
 		end
 	end

lib/sim_repo.rb 🔗

@@ -48,6 +48,17 @@ class SIMRepo
 		).then { |req| JSON.parse(req.response) }
 	end
 
+	def update_nicknames(sims)
+		iccids = PG::TextEncoder::Array.new.encode(sims.map(&:iccid))
+		nicknames = PG::TextEncoder::Array.new.encode(sims.map(&:nickname))
+		db.query_defer(<<~SQL, [iccids, nicknames])
+			UPDATE sims SET nickname = data_table.nickname
+			FROM
+			(SELECT UNNEST($1::text[]) AS iccid, UNNEST($2::text[]) AS nickname) AS data_table
+			WHERE sims.iccid = data_table.iccid
+		SQL
+	end
+
 	def owned_by(customer)
 		customer = customer.customer_id if customer.respond_to?(:customer_id)
 		promise = db.query_defer(<<~SQL, [customer])

lib/utils.rb 🔗

@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class String
+	def presence
+		if empty?
+			nil
+		else
+			self
+		end
+	end
+end
+
+class NilClass
+	def presence
+		nil
+	end
+end

sgx_jmp.rb 🔗

@@ -14,6 +14,7 @@ require "sentry-ruby"
 require "statsd-instrument"
 
 require_relative "lib/background_log"
+require_relative "lib/utils"
 
 $stdout.sync = true
 LOG = Ougai::Logger.new(BackgroundLog.new($stdout))
@@ -103,6 +104,7 @@ require_relative "lib/transaction"
 require_relative "lib/tel_selections"
 require_relative "lib/sim_repo"
 require_relative "lib/sim_order"
+require_relative "lib/edit_sim_nicknames"
 require_relative "lib/snikket"
 require_relative "lib/welcome_message"
 require_relative "web"
@@ -778,14 +780,16 @@ Command.new(
 				SIMOrder::ESIM.for(
 					customer, **CONFIG.dig(:sims, :esim, customer.currency)
 				)
+			when "edit-nicknames"
+				EditSimNicknames.new(customer, sims)
 			else
 				Command.finish
 			end
-		}.then { |order|
+		}.then { |action|
 			Command.reply { |reply|
 				reply.allowed_actions = [:complete]
-				reply.command << order.form
-			}.then(&order.method(:complete))
+				reply.command << action.form
+			}.then(&action.method(:complete))
 		}
 	end
 }.register(self).then(&CommandList.method(:register))

test/test_sim_order.rb 🔗

@@ -0,0 +1,237 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+require "ostruct"
+
+require_relative "../lib/sim_order"
+
+class SIMOrderTest < Minitest::Test
+	SIMOrder::DB = Minitest::Mock.new
+	Transaction::DB = Minitest::Mock.new
+
+	def setup
+		@price = 5000
+		@plan_name = "better than no plan"
+		@sim_repo = Minitest::Mock.new
+		@sim = SIM.new(
+			iccid: "123",
+			nickname: nil,
+			lpa_code: "LPA:abc",
+			remaining_usage_kb: 1025,
+			remaining_days: 3,
+			notes: "no notes"
+		)
+	end
+
+	def test_for_enough_balance
+		customer = Minitest::Mock.new
+		customer.expect(:balance, 60)
+
+		result = execute_command do
+			SIMOrder.for(customer, price: @price, plan: @plan_name)
+		end
+		assert_kind_of SIMOrder, result
+		assert_mock customer
+	end
+	em :test_for_enough_balance
+
+	def test_for_insufficient_balance_with_top_up
+		customer = Minitest::Mock.new
+		customer.expect(:balance, 40)
+
+		LowBalance::AutoTopUp.stub(
+			:for,
+			OpenStruct.new(can_top_up?: true),
+			[customer, @price]
+		) do
+			result = execute_command do
+				SIMOrder.for(customer, price: @price, plan: @plan_name)
+			end
+			assert_kind_of SIMOrder::WithTopUp, result
+			assert_mock customer
+		end
+	end
+	em :test_for_insufficient_balance_with_top_up
+
+	def test_for_insufficient_balance_please_top_up
+		customer = Minitest::Mock.new
+		customer.expect(:balance, 40)
+
+		LowBalance::AutoTopUp.stub(
+			:for,
+			OpenStruct.new(can_top_up?: false),
+			[customer, @price]
+		) do
+			result = execute_command do
+				SIMOrder.for(customer, price: @price, plan: @plan_name)
+			end
+			assert_kind_of SIMOrder::PleaseTopUp, result
+			assert_mock customer
+		end
+	end
+	em :test_for_insufficient_balance_please_top_up
+
+	def test_complete_nil_nick
+		customer = Minitest::Mock.new
+		customer.expect(:balance, 100)
+		customer.expect(
+			:stanza_from,
+			EMPromise.resolve(nil),
+			[Blather::Stanza::Message]
+		)
+		customer.expect(:customer_id, "123")
+
+		SIMOrder::DB.expect(
+			:transaction,
+			EMPromise.resolve(@sim)
+		) do |&blk|
+			blk.call
+		end
+
+		Transaction::DB.expect(
+			:exec,
+			nil,
+			[
+				String,
+				Matching.new { |params|
+					assert_equal "123", params[0]
+					assert_equal "tx123", params[1]
+					assert_kind_of Time, params[2]
+					assert_kind_of Time, params[3]
+					assert_in_delta(-(@price / 100).to_d, params[4], 0.05)
+					assert_equal "SIM Activation 123", params[5]
+				}
+			]
+		)
+
+		@sim_repo.expect(:available, EMPromise.resolve(@sim), [])
+		@sim_repo.expect(:put_owner, nil, [@sim, customer, "SIM"])
+		@sim_repo.expect(
+			:refill,
+			EMPromise.resolve(OpenStruct.new(ack: "success", transaction_id: "tx123"))
+		) do |refill_sim, amount_mb:|
+			@sim == refill_sim && amount_mb == 1024
+		end
+
+		order_form = Blather::Stanza::Iq::Command.new.tap { |iq|
+			iq.form.fields = [
+				{ var: "addr", value: "123 Main St" },
+				{ var: "nickname", value: nil }
+			]
+		}
+
+		blather = Minitest::Mock.new
+		blather.expect(
+			:<<,
+			EMPromise.resolve(:test_result),
+			[Matching.new do |reply|
+				assert_equal :completed, reply.status
+				assert_equal :info, reply.note_type
+				assert_equal(
+					"You will receive an notice from support when your SIM ships.",
+					reply.note.content
+				)
+			end]
+		)
+		sim_order = SIMOrder.for(customer, price: @price, plan: @plan_name)
+		sim_order.instance_variable_set(:@sim_repo, @sim_repo)
+
+		assert_equal(
+			:test_result,
+			execute_command(blather: blather) { sim_order.complete(order_form) }
+  )
+
+		assert_mock Transaction::DB
+		assert_mock SIMOrder::DB
+		assert_mock customer
+		assert_mock @sim_repo
+		assert_mock blather
+	end
+	em :test_complete_nil_nick
+
+	def test_complete_nick_present
+		customer = Minitest::Mock.new
+		customer.expect(:balance, 100)
+		customer.expect(
+			:stanza_from,
+			EMPromise.resolve(nil),
+			[Blather::Stanza::Message]
+		)
+		customer.expect(
+			:customer_id,
+			"123",
+			[]
+		)
+		Transaction::DB.expect(
+			:exec,
+			nil,
+			[
+				String,
+				Matching.new { |params|
+					assert_equal "123", params[0]
+					assert_equal "tx123", params[1]
+					assert_kind_of Time, params[2]
+					assert_kind_of Time, params[3]
+					assert_in_delta(-(@price / 100).to_d, params[4], 0.05)
+					assert_equal "SIM Activation 123", params[5]
+				}
+			]
+		)
+
+		SIMOrder::DB.expect(
+			:transaction,
+			@sim
+		) do |&blk|
+			blk.call
+		end
+
+		@sim_repo.expect(:available, EMPromise.resolve(@sim), [])
+		@sim_repo.expect(
+			:put_owner,
+			nil,
+			[@sim, customer, "test_nick"]
+		)
+		@sim_repo.expect(
+			:refill,
+			EMPromise.resolve(OpenStruct.new(ack: "success", transaction_id: "tx123"))
+		) do |refill_sim, amount_mb:|
+			@sim == refill_sim && amount_mb == 1024
+		end
+
+		order_form = Blather::Stanza::Iq::Command.new.tap { |iq|
+			iq.form.fields = [
+				{ var: "addr", value: "123 Main St" },
+				{ var: "nickname", value: "test_nick" }
+			]
+		}
+
+		blather = Minitest::Mock.new
+		blather.expect(
+			:<<,
+			EMPromise.resolve(:test_result),
+			[Matching.new do |reply|
+				assert_equal :completed, reply.status
+				assert_equal :info, reply.note_type
+				assert_equal(
+					"You will receive an notice from support when your SIM ships.",
+					reply.note.content
+				)
+			end]
+		)
+		sim_order = SIMOrder.for(customer, price: @price, plan: @plan_name)
+		sim_order.instance_variable_set(:@sim_repo, @sim_repo)
+
+		assert_equal(
+			:test_result,
+			execute_command(blather: blather) { sim_order.complete(order_form) }
+  )
+
+		assert_mock Transaction::DB
+		assert_mock SIMOrder::DB
+		assert_mock customer
+		assert_mock @sim_repo
+		assert_mock blather
+	end
+	em :test_complete_nick_present
+end