~singpolyma/sgx-jmp

1f77cd2897d0ec3e45f823f309d38b58879d2d23 — Stephen Paul Weber 8 months ago 36dbdd7 + a16fd0d
Merge branch 'number-search'

* number-search:
  Do not direct back to website to pick new number
  Run rubocop after tests
  Add reference to option to show where the tel is
  Format tel for human reader
  Allow user to search for numbers over XMPP
  s/WebRegisterManager/TelSelections
14 files changed, 555 insertions(+), 92 deletions(-)

M .rubocop.yml
M Rakefile
A forms/tn_list.rb
A forms/tn_search.rb
A lib/form_template.rb
M lib/registration.rb
A lib/tel_selections.rb
D lib/web_register_manager.rb
M sgx_jmp.rb
A test/test_form_template.rb
M test/test_helper.rb
M test/test_registration.rb
A test/test_tel_selections.rb
D test/test_web_register_manager.rb
M .rubocop.yml => .rubocop.yml +7 -0
@@ 54,6 54,9 @@ Style/DoubleNegation:
Style/PerlBackrefs:
  Enabled: false

Style/SpecialGlobalVars:
  EnforcedStyle: use_perl_names

Style/RegexpLiteral:
  EnforcedStyle: slashes
  AllowInnerSlashes: true


@@ 82,5 85,9 @@ Style/FormatString:
Style/FormatStringToken:
  EnforcedStyle: unannotated

Style/FrozenStringLiteralComment:
  Exclude:
    - forms/*

Naming/AccessorMethodName:
  Enabled: false

M Rakefile => Rakefile +1 -1
@@ 17,7 17,7 @@ end
RuboCop::RakeTask.new(:lint)

task :entr do
	sh "sh", "-c", "git ls-files | entr -s 'rubocop && rake test'"
	sh "sh", "-c", "git ls-files | entr -s 'rake test && rubocop'"
end

task default: :test

A forms/tn_list.rb => forms/tn_list.rb +14 -0
@@ 0,0 1,14 @@
form!
title "Choose Telephone Number"
instructions "Please choose one of the following numbers"
field(
	var: "tel",
	required: true,
	type: "list-single",
	label: "Telephone Number",
	options: @tns.map(&:option)
)

xml.set(xmlns: "http://jabber.org/protocol/rsm") do |xml|
	xml.count @tns.length.to_s
end

A forms/tn_search.rb => forms/tn_search.rb +15 -0
@@ 0,0 1,15 @@
form!
title "Search Telephone Numbers"
instructions @error if @error
field(
	var: "q",
	required: true,
	type: "text-single",
	label: "Search Telephone Numbers",
	description:
		"Enter one of: Area code; six or seven digit " \
		"number prefix; zip code; city, state/province; " \
		"or indicate a vanity pattern with ~"
)

xml.set(xmlns: "http://jabber.org/protocol/rsm")

A lib/form_template.rb => lib/form_template.rb +64 -0
@@ 0,0 1,64 @@
# frozen_string_literal: true

require "blather"

class FormTemplate
	def initialize(template, filename="template", **kwargs)
		@args = kwargs
		@template = template
		@filename = filename
		freeze
	end

	def self.render(path, **kwargs)
		full_path = File.dirname(__dir__) + "/forms/#{path}.rb"
		new(File.read(full_path), full_path, **kwargs).render
	end

	def render(**kwargs)
		one = OneRender.new(**@args.merge(kwargs))
		one.instance_eval(@template, @filename)
		one.form
	end

	class OneRender
		def initialize(**kwargs)
			kwargs.each do |k, v|
				instance_variable_set("@#{k}", v)
			end
			@__form = Blather::Stanza::X.new
			@__builder = Nokogiri::XML::Builder.with(@__form)
		end

		def form!
			@__type_set = true
			@__form.type = :form
		end

		def result!
			@__type_set = true
			@__form.type = :result
		end

		def title(s)
			@__form.title = s
		end

		def instructions(s)
			@__form.instructions = s
		end

		def field(**kwargs)
			@__form.fields = @__form.fields + [kwargs]
		end

		def xml
			@__builder
		end

		def form
			raise "Type never set" unless @__type_set
			@__form
		end
	end
end

M lib/registration.rb => lib/registration.rb +12 -10
@@ 9,15 9,15 @@ require_relative "./command"
require_relative "./bandwidth_tn_order"
require_relative "./em"
require_relative "./oob"
require_relative "./web_register_manager"
require_relative "./tel_selections"

class Registration
	def self.for(customer, web_register_manager)
	def self.for(customer, tel_selections)
		customer.registered?.then do |registered|
			if registered
				Registered.new(registered.phone)
			else
				web_register_manager[customer.jid].then(&:choose_tel).then do |tel|
				tel_selections[customer.jid].then(&:choose_tel).then do |tel|
					Activation.for(customer, tel)
				end
			end


@@ 425,18 425,20 @@ class Registration
		def write
			BandwidthTNOrder.create(@tel).then(&:poll).then(
				->(_) { customer_active_tel_purchased },
				lambda do |_|
					Command.finish(
						"The JMP number #{@tel} is no longer available, " \
						"please visit https://jmp.chat and choose another.",
						type: :error
					)
				end
				->(_) { number_purchase_error }
			)
		end

	protected

		def number_purchase_error
			TEL_SELECTIONS.delete(@customer.jid).then {
				TelSelections::ChooseTel.new.choose_tel(
					error: "The JMP number #{@tel} is no longer available."
				)
			}.then { |tel| Finish.new(@customer, tel).write }
		end

		def cheogram_sip_addr
			"sip:#{ERB::Util.url_encode(@customer.jid)}@sip.cheogram.com"
		end

A lib/tel_selections.rb => lib/tel_selections.rb +198 -0
@@ 0,0 1,198 @@
# frozen_string_literal: true

require "ruby-bandwidth-iris"
Faraday.default_adapter = :em_synchrony

require_relative "form_template"

class TelSelections
	THIRTY_DAYS = 60 * 60 * 24 * 30

	def initialize(redis: REDIS)
		@redis = redis
	end

	def set(jid, tel)
		@redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel)
	end

	def delete(jid)
		@redis.del("pending_tel_for-#{jid}")
	end

	def [](jid)
		@redis.get("pending_tel_for-#{jid}").then do |tel|
			tel ? HaveTel.new(tel) : ChooseTel.new
		end
	end

	class HaveTel
		def initialize(tel)
			@tel = tel
		end

		def choose_tel
			EMPromise.resolve(@tel)
		end
	end

	class ChooseTel
		def choose_tel(error: nil)
			Command.reply { |reply|
				reply.allowed_actions = [:next]
				reply.command << FormTemplate.render("tn_search", error: error)
			}.then { |iq| choose_from_list(AvailableNumber.for(iq.form).tns) }
		end

		def choose_from_list(tns)
			if tns.empty?
				choose_tel(error: "No numbers found, try another search.")
			else
				Command.reply { |reply|
					reply.allowed_actions = [:next]
					reply.command << FormTemplate.render("tn_list", tns: tns)
				}.then { |iq| iq.form.field("tel").value.to_s.strip }
			end
		end

		class AvailableNumber
			def self.for(form)
				new(
					Q
					.for(form.field("q").value.to_s.strip).iris_query
					.merge(enableTNDetail: true)
					.merge(Quantity.for(form).iris_query)
				)
			end

			def initialize(iris_query)
				@iris_query = iris_query
			end

			def tns
				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
				BandwidthIris::AvailableNumber.list(@iris_query).map(&Tn.method(:new))
			end

			class Quantity
				def self.for(form)
					rsm_max = form.find(
						"ns:set/ns:max",
						ns: "http://jabber.org/protocol/rsm"
					).first
					if rsm_max
						new(rsm_max.content.to_i)
					else
						Default.new
					end
				end

				def initialize(quantity)
					@quantity = quantity
				end

				def iris_query
					{ quantity: @quantity }
				end

				# NOTE: Gajim sends back the whole list on submit, so big
				# lists can cause issues
				class Default
					def iris_query
						{ quantity: 10 }
					end
				end
			end
		end

		class Tn
			attr_reader :tel

			def initialize(full_number:, city:, state:, **)
				@tel = "+1#{full_number}"
				@locality = city
				@region = state
			end

			def formatted_tel
				@tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
				"(#{$1}) #{$2}-#{$3}"
			end

			def option
				op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
				op << reference
				op
			end

			def reference
				Nokogiri::XML::Builder.new { |xml|
					xml.reference(
						xmlns: "urn:xmpp:reference:0",
						begin: 0,
						end: formatted_tel.length - 1,
						type: "data",
						uri: "tel:#{tel}"
					)
				}.doc.root
			end

			def to_s
				"#{formatted_tel} (#{@locality}, #{@region})"
			end
		end

		class Q
			def self.register(regex, &block)
				@queries ||= []
				@queries << [regex, block]
			end

			def self.for(q)
				@queries.each do |(regex, block)|
					match_data = (q =~ regex)
					return block.call($1 || $&, *$~.to_a[2..-1]) if match_data
				end

				raise "Format not recognized: #{q}"
			end

			def initialize(q)
				@q = q
			end

			{
				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/],
				zip: [:PostalCode, /\A\d{5}(?:-\d{4})?\Z/],
				localVanity: [:LocalVanity, /\A~(.+)\Z/]
			}.each do |k, args|
				klass = const_set(
					args[0],
					Class.new(Q) do
						define_method(:iris_query) do
							{ k => @q }
						end
					end
				)

				args[1..-1].each do |regex|
					register(regex) { |q| klass.new(q) }
				end
			end

			class CityState
				Q.register(/\A([^,]+)\s*,\s*([A-Z]{2})\Z/, &method(:new))
				def initialize(city, state)
					@city = city
					@state = state
				end

				def iris_query
					{ city: @city, state: @state }
				end
			end
		end
	end
end

D lib/web_register_manager.rb => lib/web_register_manager.rb +0 -39
@@ 1,39 0,0 @@
# frozen_string_literal: true

class WebRegisterManager
	THIRTY_DAYS = 60 * 60 * 24 * 30

	def initialize(redis: REDIS)
		@redis = redis
	end

	def set(jid, tel)
		@redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel)
	end

	def [](jid)
		@redis.get("pending_tel_for-#{jid}").then do |tel|
			tel ? HaveTel.new(tel) : ChooseTel.new
		end
	end

	class HaveTel
		def initialize(tel)
			@tel = tel
		end

		def choose_tel
			EMPromise.resolve(@tel)
		end
	end

	class ChooseTel
		def choose_tel
			Command.finish(
				"You have not chosen a phone number yet, please return to " \
				"https://jmp.chat and choose one now.",
				type: :error
			)
		end
	end
end

M sgx_jmp.rb => sgx_jmp.rb +4 -4
@@ 77,7 77,7 @@ require_relative "lib/low_balance"
require_relative "lib/payment_methods"
require_relative "lib/registration"
require_relative "lib/transaction"
require_relative "lib/web_register_manager"
require_relative "lib/tel_selections"
require_relative "lib/session_manager"
require_relative "lib/statsd"



@@ 169,7 169,7 @@ when_ready do
	LOG.info "Ready"
	BLATHER = self
	REDIS = EM::Hiredis.connect
	WEB_REGISTER_MANAGER = WebRegisterManager.new
	TEL_SELECTIONS = TelSelections.new
	BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
	DB = PG::EM::ConnectionPool.new(dbname: "jmp") do |conn|
		conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn)


@@ 392,7 392,7 @@ Command.new(
		Command.execution.customer_repo.create(Command.execution.iq.from.stripped)
	}.then { |customer|
		Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Registration.for"))
		Registration.for(customer, WEB_REGISTER_MANAGER).then(&:write)
		Registration.for(customer, TEL_SELECTIONS).then(&:write)
	}.then {
		StatsD.increment("registration.completed")
	}.catch_only(Command::Execution::FinalStanza) do |e|


@@ 547,7 547,7 @@ command :execute?, node: "web-register", sessionid: nil do |iq|
				cmd.form.fields = [var: "to", value: jid]
				cmd.form.type = "submit"
			}).then { |result|
				WEB_REGISTER_MANAGER.set(result.form.field("from")&.value.to_s.strip, tel)
				TEL_SELECTIONS.set(result.form.field("from")&.value.to_s.strip, tel)
			}.then {
				BLATHER << iq.reply.tap { |reply| reply.status = :completed }
			}.catch { |e| panic(e, sentry_hub) }

A test/test_form_template.rb => test/test_form_template.rb +65 -0
@@ 0,0 1,65 @@
# frozen_string_literal: true

require "test_helper"
require "form_template"

class FormTemplateTest < Minitest::Test
	def test_form_one_field
		template = FormTemplate.new(<<~TEMPLATE)
			form!
			title "TITLE"
			instructions "INSTRUCTIONS"
			field(var: "thevar", label: "thelabel")
		TEMPLATE
		form = template.render
		assert_equal :form, form.type
		assert_equal "TITLE", form.title
		assert_equal "INSTRUCTIONS", form.instructions
		assert_equal 1, form.fields.length
		assert_equal "thevar", form.fields[0].var
		assert_equal "thelabel", form.fields[0].label
	end

	def test_form_two_fields
		template = FormTemplate.new(<<~TEMPLATE)
			form!
			field(var: "thevar", label: "thelabel")
			field(var: "thevar2", label: "thelabel2")
		TEMPLATE
		form = template.render
		assert_equal 2, form.fields.length
		assert_equal "thevar", form.fields[0].var
		assert_equal "thelabel", form.fields[0].label
		assert_equal "thevar2", form.fields[1].var
		assert_equal "thelabel2", form.fields[1].label
	end

	def test_result_no_fields
		template = FormTemplate.new(<<~TEMPLATE)
			result!
			title "TITLE"
			instructions "INSTRUCTIONS"
		TEMPLATE
		form = template.render
		assert_equal :result, form.type
		assert_equal "TITLE", form.title
		assert_equal "INSTRUCTIONS", form.instructions
	end

	def test_no_type
		template = FormTemplate.new(<<~TEMPLATE)
			title "TITLE"
			instructions "INSTRUCTIONS"
		TEMPLATE
		assert_raises { template.render }
	end

	def test_custom_xml
		template = FormTemplate.new(<<~TEMPLATE)
			form!
			xml.whoever @arg
		TEMPLATE
		form = template.render(arg: "abc")
		assert_equal "abc", form.at("whoever").content
	end
end

M test/test_helper.rb => test/test_helper.rb +22 -0
@@ 34,6 34,7 @@ rescue LoadError
end

require "backend_sgx"
require "tel_selections"

$VERBOSE = nil
Sentry.init


@@ 132,6 133,27 @@ class PromiseMock < Minitest::Mock
	end
end

class FakeTelSelections
	def initialize
		@selections = {}
	end

	def set(jid, tel)
		@selections[jid] = EMPromise.resolve(TelSelections::HaveTel.new(tel))
	end

	def delete(jid)
		@selections.delete(jid)
		EMPromise.resolve("OK")
	end

	def [](jid)
		@selections.fetch(jid) do
			TelSelections::ChooseTel.new
		end
	end
end

class FakeRedis
	def initialize(values={})
		@values = values

M test/test_registration.rb => test/test_registration.rb +22 -14
@@ 35,7 35,7 @@ class RegistrationTest < Minitest::Test
	em :test_for_registered

	def test_for_activated
		web_manager = WebRegisterManager.new(redis: FakeRedis.new)
		web_manager = TelSelections.new(redis: FakeRedis.new)
		web_manager.set("test@example.net", "+15555550000")
		result = execute_command do
			sgx = OpenStruct.new(registered?: EMPromise.resolve(nil))


@@ 54,7 54,7 @@ class RegistrationTest < Minitest::Test

	def test_for_not_activated_with_customer_id
		sgx = OpenStruct.new(registered?: EMPromise.resolve(nil))
		web_manager = WebRegisterManager.new(redis: FakeRedis.new)
		web_manager = TelSelections.new(redis: FakeRedis.new)
		web_manager.set("test@example.net", "+15555550000")
		iq = Blather::Stanza::Iq::Command.new
		iq.from = "test@example.com"


@@ 514,6 514,8 @@ class RegistrationTest < Minitest::Test
	end

	class FinishTest < Minitest::Test
		Command::COMMAND_MANAGER = Minitest::Mock.new
		Registration::Finish::TEL_SELECTIONS = FakeTelSelections.new
		Registration::Finish::REDIS = Minitest::Mock.new
		BackendSgx::REDIS = Minitest::Mock.new



@@ 628,23 630,29 @@ class RegistrationTest < Minitest::Test
					<OrderStatus>FAILED</OrderStatus>
				</OrderResponse>
			RESPONSE
			blather = Minitest::Mock.new
			blather.expect(
				:<<,
				nil,
				[Matching.new do |reply|
					assert_equal :completed, reply.status
					assert_equal :error, reply.note_type

			Command::COMMAND_MANAGER.expect(
				:write,
				EMPromise.reject(:test_result),
				[Matching.new do |iq|
					assert_equal :form, iq.form.type
					assert_equal(
						"The JMP number +15555550000 is no longer available, " \
						"please visit https://jmp.chat and choose another.",
						reply.note.content
						"The JMP number +15555550000 is no longer available.",
						iq.form.instructions
					)
				end]
			)
			execute_command(blather: blather) { @finish.write }

			assert_equal(
				:test_result,
				execute_command { @finish.write.catch { |e| e } }
			)
			assert_mock Command::COMMAND_MANAGER
			assert_instance_of(
				TelSelections::ChooseTel,
				Registration::Finish::TEL_SELECTIONS["test@example.com"]
			)
			assert_requested create_order
			assert_mock blather
		end
		em :test_write_tn_fail
	end

A test/test_tel_selections.rb => test/test_tel_selections.rb +131 -0
@@ 0,0 1,131 @@
# frozen_string_literal: true

require "test_helper"
require "tel_selections"

class TelSelectionsTest < Minitest::Test
	def setup
		@manager = TelSelections.new(redis: FakeRedis.new)
	end

	def test_set_get
		assert_kind_of TelSelections::ChooseTel, @manager["jid@example.com"].sync
		@manager.set("jid@example.com", "+15555550000").sync
		assert_kind_of TelSelections::HaveTel, @manager["jid@example.com"].sync
	end
	em :test_set_get

	def test_choose_tel_have_tel
		jid = "jid@example.com"
		@manager.set(jid, "+15555550000").sync
		assert_equal "+15555550000", @manager[jid].then(&:choose_tel).sync
	end
	em :test_choose_tel_have_tel

	class AvailableNumberTest < Minitest::Test
		def test_for_no_rsm
			form = Blather::Stanza::X.new
			form.fields = [{ var: "q", value: "226" }]
			iris_query =
				TelSelections::ChooseTel::AvailableNumber
				.for(form)
				.instance_variable_get(:@iris_query)
			assert_equal(
				{ areaCode: "226", enableTNDetail: true, quantity: 10 },
				iris_query
			)
		end

		def test_for_rsm
			form = Blather::Stanza::X.new
			form.fields = [{ var: "q", value: "226" }]
			Nokogiri::XML::Builder.with(form) do
				set(xmlns: "http://jabber.org/protocol/rsm") do
					max 500
				end
			end
			iris_query =
				TelSelections::ChooseTel::AvailableNumber
				.for(form)
				.instance_variable_get(:@iris_query)
			assert_equal(
				{ areaCode: "226", enableTNDetail: true, quantity: 500 },
				iris_query
			)
		end
	end

	class TnTest < Minitest::Test
		def setup
			@tn = TelSelections::ChooseTel::Tn.new(
				full_number: "5551234567",
				city: "Toronto",
				state: "ON",
				garbage: "stuff"
			)
		end

		def test_to_s
			assert_equal "(555) 123-4567 (Toronto, ON)", @tn.to_s
		end

		def test_tel
			assert_equal "+15551234567", @tn.tel
		end

		def test_option
			assert_equal(
				Blather::Stanza::X::Field::Option.new(
					label: "(555) 123-4567 (Toronto, ON)",
					value: "+15551234567"
				),
				@tn.option
			)
		end

		def test_option_reference
			ref = @tn.option.find("ns:reference", ns: "urn:xmpp:reference:0").first
			assert_equal(
				@tn.formatted_tel,
				@tn.option.label[ref["begin"].to_i..ref["end"].to_i]
			)
			assert_equal "tel:+15551234567", ref["uri"]
		end
	end

	class QTest < Minitest::Test
		def test_for_area_code
			q = TelSelections::ChooseTel::Q.for("226")
			assert_equal({ areaCode: "226" }, q.iris_query)
		end

		def test_for_npanxx
			q = TelSelections::ChooseTel::Q.for("226666")
			assert_equal({ npaNxx: "226666" }, q.iris_query)
		end

		def test_for_npanxxx
			q = TelSelections::ChooseTel::Q.for("2266667")
			assert_equal({ npaNxxx: "2266667" }, q.iris_query)
		end

		def test_for_zip
			q = TelSelections::ChooseTel::Q.for("90210")
			assert_equal({ zip: "90210" }, q.iris_query)
		end

		def test_for_localvanity
			q = TelSelections::ChooseTel::Q.for("~mboa")
			assert_equal({ localVanity: "mboa" }, q.iris_query)
		end

		def test_for_citystate
			q = TelSelections::ChooseTel::Q.for("Toronto, ON")
			assert_equal({ city: "Toronto", state: "ON" }, q.iris_query)
		end

		def test_for_garbage
			assert_raises { TelSelections::ChooseTel::Q.for("garbage") }
		end
	end
end

D test/test_web_register_manager.rb => test/test_web_register_manager.rb +0 -24
@@ 1,24 0,0 @@
# frozen_string_literal: true

require "test_helper"
require "web_register_manager"

class WebRegisterManagerTest < Minitest::Test
	def setup
		@manager = WebRegisterManager.new(redis: FakeRedis.new)
	end

	def test_set_get
		assert_kind_of WebRegisterManager::ChooseTel, @manager["jid@example.com"].sync
		@manager.set("jid@example.com", "+15555550000").sync
		assert_kind_of WebRegisterManager::HaveTel, @manager["jid@example.com"].sync
	end
	em :test_set_get

	def test_choose_tel_have_tel
		jid = "jid@example.com"
		@manager.set(jid, "+15555550000").sync
		assert_equal "+15555550000", @manager[jid].then(&:choose_tel).sync
	end
	em :test_choose_tel_have_tel
end