Merge branch 'area_codes'

Stephen Paul Weber created

* area_codes:
  When no results for a city, try area codes nearby

Change summary

lib/registration.rb         |   4 +
lib/tel_selections.rb       | 103 ++++++++++++++++++++++++++++++++------
sgx_jmp.rb                  |   3 
test/test_helper.rb         |  20 +++++++
test/test_registration.rb   |  16 ++++-
test/test_tel_selections.rb |  65 +++++++++++++++++++++++-
6 files changed, 183 insertions(+), 28 deletions(-)

Detailed changes

lib/registration.rb 🔗

@@ -542,7 +542,9 @@ class Registration
 		def number_purchase_error(e)
 			Command.log.error "number_purchase_error", e
 			TEL_SELECTIONS.delete(@customer.jid).then {
-				TelSelections::ChooseTel.new.choose_tel(
+				TEL_SELECTIONS[@customer.jid]
+			}.then { |choose|
+				choose.choose_tel(
 					error: "The JMP number #{@tel} is no longer available."
 				)
 			}.then { |tel| Finish.new(@customer, tel).write }

lib/tel_selections.rb 🔗

@@ -3,13 +3,18 @@
 require "ruby-bandwidth-iris"
 Faraday.default_adapter = :em_synchrony
 
+require "cbor"
+
+require_relative "area_code_repo"
 require_relative "form_template"
 
 class TelSelections
 	THIRTY_DAYS = 60 * 60 * 24 * 30
 
-	def initialize(redis: REDIS)
+	def initialize(redis: REDIS, db: DB, memcache: MEMCACHE)
 		@redis = redis
+		@memcache = memcache
+		@db = db
 	end
 
 	def set(jid, tel)
@@ -22,7 +27,7 @@ class TelSelections
 
 	def [](jid)
 		@redis.get("pending_tel_for-#{jid}").then do |tel|
-			tel ? HaveTel.new(tel) : ChooseTel.new
+			tel ? HaveTel.new(tel) : ChooseTel.new(db: @db, memcache: @memcache)
 		end
 	end
 
@@ -37,12 +42,17 @@ class TelSelections
 	end
 
 	class ChooseTel
+		def initialize(db: DB, memcache: MEMCACHE)
+			@db = db
+			@memcache = memcache
+		end
+
 		def choose_tel(error: nil)
 			Command.reply { |reply|
 				reply.allowed_actions = [:next]
 				reply.command << FormTemplate.render("tn_search", error: error)
 			}.then do |iq|
-				available = AvailableNumber.for(iq.form)
+				available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
 				next available if available.is_a?(String)
 
 				choose_from_list(available.tns)
@@ -66,27 +76,57 @@ class TelSelections
 		end
 
 		class AvailableNumber
-			def self.for(form)
-				q = form.field("q")&.value.to_s.strip
-				return q if q =~ /\A\+1\d{10}\Z/
+			def self.for(form, db: DB, memcache: MEMCACHE)
+				qs = form.field("q")&.value.to_s.strip
+				return qs if qs =~ /\A\+1\d{10}\Z/
+
+				q = Q.for(qs, db: db, memcache: memcache)
 
 				new(
-					Q
-					.for(q).iris_query
+					q.iris_query
 					.merge(enableTNDetail: true, LCA: false)
-					.merge(Quantity.for(form).iris_query)
+					.merge(Quantity.for(form).iris_query),
+					fallback: q.fallback,
+					memcache: memcache
 				)
 			end
 
-			def initialize(iris_query)
+			def initialize(iris_query, fallback: [], memcache: MEMCACHE)
 				@iris_query = iris_query
+				@fallback = fallback
+				@memcache = memcache
 			end
 
 			def tns
 				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
-				BandwidthIris::AvailableNumber.list(@iris_query).map do |tn|
-					Tn.new(**tn)
+				unless (result = fetch_cache)
+					result = BandwidthIris::AvailableNumber.list(@iris_query)
 				end
+				return next_fallback if result.empty? && !@fallback.empty?
+
+				result.map { |tn| Tn.new(**tn) }
+			end
+
+			def next_fallback
+				@memcache.set(cache_key, CBOR.encode([]), 43200)
+				self.class.new(
+					@fallback.shift.iris_query.merge(
+						enableTNDetail: true, quantity: @iris_query[:quantity]
+					),
+					fallback: @fallback,
+					memcache: @memcache
+				).tns
+			end
+
+			def fetch_cache
+				promise = EMPromise.new
+				@memcache.get(cache_key, &promise.method(:fulfill))
+				result = promise.sync
+				result ? CBOR.decode(result) : nil
+			end
+
+			def cache_key
+				"BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
 			end
 
 			class Quantity
@@ -163,10 +203,10 @@ class TelSelections
 				@queries << [regex, block]
 			end
 
-			def self.for(q)
+			def self.for(q, **kwa)
 				@queries.each do |(regex, block)|
 					match_data = (q =~ regex)
-					return block.call($1 || $&, *$~.to_a[2..-1]) if match_data
+					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
 				end
 
 				raise "Format not recognized: #{q}"
@@ -176,6 +216,10 @@ class TelSelections
 				@q = q
 			end
 
+			def fallback
+				[]
+			end
+
 			{
 				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
 				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
@@ -193,7 +237,7 @@ class TelSelections
 				)
 
 				args[1..-1].each do |regex|
-					register(regex) { |q| klass.new(q) }
+					register(regex) { |q, **| klass.new(q) }
 				end
 			end
 
@@ -204,13 +248,21 @@ class TelSelections
 					"QC" => "PQ"
 				}.freeze
 
-				def initialize(state)
+				def initialize(state, **)
 					@state = STATE_MAP.fetch(state.upcase, state.upcase)
 				end
 
+				def fallback
+					[]
+				end
+
 				def iris_query
 					{ state: @state }
 				end
+
+				def to_s
+					@state
+				end
 			end
 
 			class CityState
@@ -229,14 +281,31 @@ class TelSelections
 					"west durham" => "Durham"
 				}.freeze
 
-				def initialize(city, state)
+				def initialize(city, state, db: DB, memcache: MEMCACHE)
 					@city = CITY_MAP.fetch(city.downcase, city)
 					@state = State.new(state)
+					@db = db
+					@memcache = memcache
+				end
+
+				def fallback
+					LazyObject.new do
+						AreaCodeRepo.new(
+							db: @db,
+							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
+						).find(to_s).sync.map { |area_code|
+							AreaCode.new(area_code)
+						}
+					end
 				end
 
 				def iris_query
 					@state.iris_query.merge(city: @city)
 				end
+
+				def to_s
+					"#{@city}, #{@state}"
+				end
 			end
 		end
 	end

sgx_jmp.rb 🔗

@@ -238,9 +238,10 @@ when_ready do
 	log.info "Ready"
 	BLATHER = self
 	REDIS = EM::Hiredis.connect
-	TEL_SELECTIONS = TelSelections.new
+	MEMCACHE = EM::P::Memcache.connect
 	BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
 	DB = Postgres.connect(dbname: "jmp")
+	TEL_SELECTIONS = TelSelections.new
 
 	DB.hold do |conn|
 		conn.query("LISTEN low_balance")

test/test_helper.rb 🔗

@@ -138,6 +138,8 @@ LOG = Class.new {
 		Minitest::Mock.new
 	end
 
+	def debug(*); end
+
 	def info(*); end
 
 	def error(*); end
@@ -210,7 +212,7 @@ class FakeTelSelections
 
 	def [](jid)
 		@selections.fetch(jid) do
-			TelSelections::ChooseTel.new
+			TelSelections::ChooseTel.new(db: FakeDB.new, memcache: FakeMemcache.new)
 		end
 	end
 end
@@ -338,6 +340,22 @@ class FakeDB
 	end
 end
 
+class FakeMemcache
+	def initialize(data={})
+		@data = data
+	end
+
+	def set(k, v, _expires=nil)
+		raise "No spaces" if k =~ /\s/
+
+		@data[k] = v
+	end
+
+	def get(k)
+		yield @data[k]
+	end
+end
+
 class FakeLog
 	def initialize
 		@logs = []

test/test_registration.rb 🔗

@@ -29,7 +29,9 @@ class RegistrationTest < Minitest::Test
 			:post,
 			"https://dashboard.bandwidth.com/v1.0/accounts//tnreservation"
 		)
-		web_manager = TelSelections.new(redis: FakeRedis.new)
+		web_manager = TelSelections.new(
+			redis: FakeRedis.new, db: FakeDB.new, memcache: FakeMemcache.new
+		)
 		web_manager.set("test@example.net", "+15555550000")
 		result = execute_command do
 			sgx = OpenStruct.new(registered?: false)
@@ -50,7 +52,9 @@ class RegistrationTest < Minitest::Test
 
 	def test_for_not_activated_approved
 		sgx = OpenStruct.new(registered?: false)
-		web_manager = TelSelections.new(redis: FakeRedis.new)
+		web_manager = TelSelections.new(
+			redis: FakeRedis.new, db: FakeDB.new, memcache: FakeMemcache.new
+		)
 		web_manager.set("test\\40approved.example.com@component", "+15555550000")
 		iq = Blather::Stanza::Iq::Command.new
 		iq.from = "test@approved.example.com"
@@ -70,7 +74,9 @@ class RegistrationTest < Minitest::Test
 
 	def test_for_not_activated_googleplay
 		sgx = OpenStruct.new(registered?: false)
-		web_manager = TelSelections.new(redis: FakeRedis.new)
+		web_manager = TelSelections.new(
+			redis: FakeRedis.new, db: FakeDB.new, memcache: FakeMemcache.new
+		)
 		web_manager.set("test@example.net", "+15555550000")
 		iq = Blather::Stanza::Iq::Command.new
 		iq.from = "test@approved.example.com"
@@ -87,7 +93,9 @@ class RegistrationTest < Minitest::Test
 
 	def test_for_not_activated_with_customer_id
 		sgx = OpenStruct.new(registered?: false)
-		web_manager = TelSelections.new(redis: FakeRedis.new)
+		web_manager = TelSelections.new(
+			redis: FakeRedis.new, db: FakeDB.new, memcache: FakeMemcache.new
+		)
 		web_manager.set("test@example.net", "+15555550000")
 		iq = Blather::Stanza::Iq::Command.new
 		iq.from = "test@example.com"

test/test_tel_selections.rb 🔗

@@ -5,7 +5,11 @@ require "tel_selections"
 
 class TelSelectionsTest < Minitest::Test
 	def setup
-		@manager = TelSelections.new(redis: FakeRedis.new)
+		@manager = TelSelections.new(
+			redis: FakeRedis.new,
+			db: FakeDB.new,
+			memcache: FakeMemcache.new
+		)
 	end
 
 	def test_set_get
@@ -27,7 +31,7 @@ class TelSelectionsTest < Minitest::Test
 			form = Blather::Stanza::X.new
 			form.fields = [{ var: "q", value: "226" }]
 			iris_query = TelSelections::ChooseTel::AvailableNumber
-				.for(form)
+				.for(form, db: FakeDB.new, memcache: FakeMemcache.new)
 				.instance_variable_get(:@iris_query)
 			assert_equal(
 				{ areaCode: "226", enableTNDetail: true, LCA: false, quantity: 10 },
@@ -44,13 +48,62 @@ class TelSelectionsTest < Minitest::Test
 				end
 			end
 			iris_query = TelSelections::ChooseTel::AvailableNumber
-				.for(form)
+				.for(form, db: FakeDB.new, memcache: FakeMemcache.new)
 				.instance_variable_get(:@iris_query)
 			assert_equal(
 				{ areaCode: "226", enableTNDetail: true, LCA: false, quantity: 500 },
 				iris_query
 			)
 		end
+
+		def test_fallback
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/accounts//availableNumbers" \
+				"?city=Kitchener-Waterloo&enableTNDetail=true&lCA=false&" \
+				"quantity=10&state=ON"
+			).to_return(status: 200, body: "")
+
+			stub_request(
+				:get,
+				"https://geocoder.ca/?json=1&locate=Kitchener-Waterloo,%20ON"
+			).to_return(status: 200, body: {
+				postal: "N2H", longt: 0, latt: 0
+			}.to_json)
+
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/accounts//availableNumbers" \
+				"?areaCode=226&enableTNDetail=true&quantity=10"
+			).to_return(status: 200, body: <<~XML)
+				<SearchResult>
+					<TelephoneNumberList>
+						<TelephoneNumber>
+							<FullNumber>22655512345</FullNumber>
+							<City>Somewhere</City>
+							<State>ON</State>
+						</TelephoneNumber>
+					</TelephoneNumberList>
+				</SearchResult>
+			XML
+
+			db = FakeDB.new(
+				["CA", "POINT(0.0000000000 0.0000000000)", 3] =>
+					[{ "area_code" => "226" }]
+			)
+			form = Blather::Stanza::X.new
+			form.fields = [{ var: "q", value: "Kitchener, ON" }]
+			tns = execute_command do
+				TelSelections::ChooseTel::AvailableNumber
+					.for(form, db: db, memcache: FakeMemcache.new)
+					.tns
+			end
+			assert_equal(
+				["(226) 555-12345 (Somewhere, ON)"],
+				tns.map(&:to_s)
+			)
+		end
+		em :test_fallback
 	end
 
 	class TnTest < Minitest::Test
@@ -123,7 +176,11 @@ class TelSelectionsTest < Minitest::Test
 		end
 
 		def test_for_citystate
-			q = TelSelections::ChooseTel::Q.for("Toronto, ON")
+			q = TelSelections::ChooseTel::Q.for(
+				"Toronto, ON",
+				db: FakeDB.new,
+				memcache: FakeMemcache.new
+			)
 			assert_equal({ city: "Toronto", state: "ON" }, q.iris_query)
 		end