test: groundwork for property tests

Phillip Davis created

- some extra generators for domain-specific data like NANPA-compliant
  phone numbers, Bandwidth media URLs, and JIDs (could be better, but
  nobody's perfect)
- Rake test tasks split into `unit` and `property` (existing `test`
  target runs both and is still the default)
- `property` target picks a random seed, then uses that seed to set
  `--seed=` (for minitest) and `srand` (for rantly). downside: you can
  only seed a whole test, you can rerun just one property with the same
  generated input. sad.
- `rake property` will, by default, collect failing seeds into
  `test/stubs`

Change summary

Gemfile                                            |  2 
Rakefile                                           | 75 ++++++++++++
test/property/failure_reporter.rb                  | 17 ++
test/property/generators/message.rb                | 97 ++++++++++++++++
test/property/generators/test_message.rb           | 20 +++
test/property/generators/webhook.rb                | 44 +++++++
test/property/rantly_extensions/data_extensions.rb | 50 ++++++++
7 files changed, 303 insertions(+), 2 deletions(-)

Detailed changes

Gemfile 🔗

@@ -42,4 +42,6 @@ group(:test) do
 	gem 'rubocop', '= 0.89.1'
 	gem 'simplecov', require: false
 	gem 'webmock'
+	# DSL wrapper around Rantly generators
+	gem "jennifer", github: "phdavis1027/jennifer"
 end

Rakefile 🔗

@@ -2,15 +2,86 @@
 
 require "rake/testtask"
 require "rubocop/rake_task"
+require "json"
+require "tempfile"
 
-Rake::TestTask.new(:test) do |t|
+
+Rake::TestTask.new(:unit) do |t|
 	ENV["ENV"] = "test"
+	ENV["COVERAGE_NAME"] = "unit"
+
+	t.libs << "test"
+	t.libs << "lib"
+	t.test_files = FileList["test/**/test_*.rb"].exclude("test/property/**")
+	t.warning = false
+end
 
+Rake::TestTask.new(:_run_property) do |t|
 	t.libs << "test"
+	t.libs << "test/property"
 	t.libs << "lib"
-	t.test_files = FileList["test/**/test_*.rb"]
+	t.test_files = FileList["test/property/**/test_*.rb"] +
+		FileList["test/stubs/seed_*.rb"]
+	t.ruby_opts << "-r failure_reporter"
 	t.warning = false
 end
+Rake::Task[:_run_property].clear_comments
+
+desc "Run property tests (optional seed: rake property[SEED,no-stubs])"
+task :property, [:seed, :no_stubs] do |_t, args|
+	ENV["SEED"] = (args[:seed] || rand(0xFFFF)).to_i.to_s
+	ENV["COVERAGE_NAME"] = "property"
+	srand(ENV["SEED"].to_i)
+
+	warn "Property seed: #{ENV['SEED']}"
+
+	stub_path = "test/stubs/seed_#{ENV['SEED']}.rb"
+	if args[:seed] && File.exist?(stub_path)
+		ENV["TESTOPTS"] = "--seed=#{ENV['SEED']}"
+		Rake::TestTask.new(:_run_stub) do |t|
+			t.libs << "test"
+			t.libs << "test/property"
+			t.libs << "lib"
+			t.test_files = [stub_path]
+			t.warning = false
+		end
+		Rake::Task[:_run_stub].clear_comments
+		Rake::Task[:_run_stub].invoke
+		next
+	end
+
+	report = Tempfile.new(["failures", ".json"])
+	begin
+		ENV["FAILURE_REPORT_PATH"] = report.path
+		ENV["TESTOPTS"] = "--seed=#{ENV['SEED']}"
+
+		Rake::Task[:_run_property].invoke
+	rescue RuntimeError
+		unless args[:no_stubs]
+			failing = JSON.parse(File.read(report.path))
+
+			mkdir_p "test/stubs"
+			comment = failing.map { |n| "# - #{n}" }.join("\n")
+			File.write(stub_path, <<~RUBY)
+				# frozen_string_literal: true
+
+				# Failing tests:
+				#{comment}
+
+				srand(#{ENV['SEED']})
+				require_relative "../property/test_webhook_handler"
+			RUBY
+			warn "\nStub written: #{stub_path}"
+			warn "Reproduce:    bundle exec ruby -Itest -Itest/property -Ilib #{stub_path}"
+		end
+		exit 1
+	ensure
+		report&.close
+		report&.unlink
+	end
+end
+
+task :test => [:unit, :property]
 
 RuboCop::RakeTask.new(:lint)
 

test/property/failure_reporter.rb 🔗

@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require "minitest"
+require "json"
+
+module FailureReport
+	def report
+		super
+		path = ENV["FAILURE_REPORT_PATH"]
+		return unless path
+
+		failing = results.reject(&:skipped?).map(&:name)
+		File.write(path, JSON.dump(failing))
+	end
+end
+
+Minitest::StatisticsReporter.prepend(FailureReport)

test/property/generators/message.rb 🔗

@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require "jennifer"
+require_relative "../rantly_extensions/data_extensions"
+
+class Message
+	module SegmentLimit
+		GSM_7 = 160
+		UCS_2 = 70
+	end
+	CHANNELS = ["sms", "mms", "rbm"]
+
+	# @param redis [RedisClient]
+	def initialize(redis)
+		@redis = redis
+	end
+
+	include Jennifer.rant(self) { |registered, jid, dir, top_level_to|
+		from {
+			case dir
+			when "in"
+				choose(nanpa_phone, shortcode)
+			when "out"
+				nanpa_phone
+			end
+		}
+		to derived_from(:from) {
+			case dir
+			when "in"
+				array(integer(4)) { nanpa_phone }
+			when "out"
+				array(integer(4)) { nanpa_phone } +
+					[top_level_to] +
+					array(integer(4)) { nanpa_phone }
+			end
+		}
+		owner derived_from(:to, :from) { |to, from|
+			case dir
+			when "in"
+				choose(*to)
+			when "out"
+				from
+			end
+		}
+		direction { dir }
+		time { iso8601 }
+		text {
+			# From bandwidth docs
+			# ```
+			# message.to: Empty text/string. Message text content will not be sent in callback.
+			# ```
+			case dir
+			when "in"
+				string
+			when "out"
+				""
+			end
+		}
+		media {
+			choose(nil, array(range(0, 10)) { media_url })
+		}
+		stanza_id(transient: true) { string(:alnum) }
+		resourcepart(transient: true) { string }
+		tag derived_from(:stanza_id, :resourcepart) { |stanza_id, resourcepart|
+			[stanza_id, resourcepart].join(" ")
+		}
+		id { string }
+		application_id { string }
+		channel { choose(*CHANNELS) }
+		segment_count derived_from(:channel, :text) { |channel, text|
+			next 0 unless text
+
+			case channel
+			when "mms"
+				1
+			when "sms", "rbm"
+				segment_limit = choose(SegmentLimit::GSM_7, SegmentLimit::UCS_2)
+				(text.size / segment_limit).floor +
+					(text.size % segment_limit == 0 ? 0 : 1)
+			end
+		}
+		description { string }
+		errorCode {
+			case dir
+			when "out" then range(4000, 5999).to_s
+			when "in" then nil
+			end
+		}
+		redis_state derived_from(:owner), transient: true do |owner|
+			@redis.reset!
+			@redis.set("catapult_jid-", "HERE")
+			next unless registered
+
+			@redis.set("catapult_jid-#{owner}", jid)
+		end
+	}
+end

test/property/generators/test_message.rb 🔗

@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "rantly/minitest_extensions"
+require_relative "message"
+require_relative "../rantly_extensions/data_extensions"
+
+class MessagePropertyTest < Minitest::Test
+	def test_out_direction_to_contains_top_level_to
+		property_of {
+			top_level_to = nanpa_phone
+			_, example = Message.new(REDIS)
+				.generate(true, bare_jid, "out", top_level_to)
+			[top_level_to, example]
+		}.check { |top_level_to, example|
+			assert_includes example["to"], top_level_to
+		}
+	end
+	em :test_out_direction_to_contains_top_level_to
+end

test/property/generators/webhook.rb 🔗

@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "jennifer"
+require_relative "message"
+
+class Webhook
+	# @param redis [RedisClient]
+	def initialize(redis)
+		@redis = redis
+	end
+
+	INBOUND_TYPES = ["message-received"]
+	OUTBOUND_TYPES = ["message-delivered", "message-failed"]
+
+	include Jennifer.rant(self) {
+		time { iso8601 }
+		type {
+			choose(*(INBOUND_TYPES + OUTBOUND_TYPES))
+		}
+		direction derived_from(:type) { |type|
+			if INBOUND_TYPES.include?(type)
+				"in"
+			elsif OUTBOUND_TYPES.include?(type)
+				"out"
+			else
+				raise "Generated bad webhook type #{type}"
+			end
+		}, transient: true
+		description { string }
+		to { nanpa_phone }
+
+		jid(transient: true) { bare_jid }
+		registered(transient: true) { true }
+		resend_id(transient: true) {
+			"#{sized(13) { string(/[0-9]/) }}-#{range(0, 99)}"
+		}
+		message derived_from(:registered, :jid, :direction, :to) { |registered, jid, direction, to|
+			Message.new(@redis).generate(registered, jid, direction, to)
+		}
+		written_state(transient: true) {
+			SGXbwmsgsv2.instance_variable_set(:@written, [])
+		}
+	}
+end

test/property/rantly_extensions/data_extensions.rb 🔗

@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class Rantly
+	module Data
+		module Extensions
+			# @note https://stackoverflow.com/questions/6478875/regular-expression-matching-e-164-formatted-phone-numbers
+			# @return [String]
+			def nanpa_phone
+				"+1" +
+					sized(1) { string(/[2-9]/) } +
+					sized(2) { string(/[0-9]/) } +
+					sized(1) { string(/[2-9]/) } +
+					sized(6) { string(/[0-9]/) }
+			end
+
+			# @note https://stackoverflow.com/questions/4894198/how-to-generate-a-random-date-in-ruby
+			# @return [String]
+			def iso8601(from = 0.0, to = Time.now)
+				value { Time.at(from + float * (to.to_f - from.to_f)).iso8601 }
+			end
+
+			# @return [String]
+			def bare_jid
+				local = sized(range(3, 12)) { string(:alnum) }
+				domain = sized(range(3, 8)) { string(:lower) }
+				"#{local}@#{domain}.example.com"
+			end
+
+			# @return [String]
+			def bw_message_id
+				sized(range(6, 19)) { string(:alnum) }
+			end
+
+			# @return [String]
+			def shortcode
+				range(10000, 999999).to_s
+			end
+
+			# @return [String]
+			def media_url
+				user_id = sized(range(3, 10)) { string(:alnum) }
+				name = sized(range(3, 12)) { string(:alnum) }
+				ext = choose(".jpg", ".png", ".gif", ".mp4", ".pdf", ".smil", ".txt", ".xml")
+				"https://messaging.bandwidth.com/api/v2/users/#{user_id}/media/#{name}#{ext}"
+			end
+		end
+	end
+
+	include Data::Extensions
+end