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
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`
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(-)
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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