From d13029c1c1ddeed715cea0145e4457718e7a4584 Mon Sep 17 00:00:00 2001 From: Phillip Davis Date: Tue, 3 Mar 2026 13:13:23 -0500 Subject: [PATCH] test: groundwork for property tests - 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 +++++++++ .../rantly_extensions/data_extensions.rb | 50 ++++++++++ 7 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 test/property/failure_reporter.rb create mode 100644 test/property/generators/message.rb create mode 100644 test/property/generators/test_message.rb create mode 100644 test/property/generators/webhook.rb create mode 100644 test/property/rantly_extensions/data_extensions.rb diff --git a/Gemfile b/Gemfile index 909604a5a079948a6269102e3a65d2b241c93239..bb857786caf947d415ef7e367749994c0f859348 100644 --- a/Gemfile +++ b/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 diff --git a/Rakefile b/Rakefile index e76316c23c6a9119099dda2cc10fd68658c99a09..ca82f2a6776295f00a09873f796cbedb402c9f07 100644 --- a/Rakefile +++ b/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) diff --git a/test/property/failure_reporter.rb b/test/property/failure_reporter.rb new file mode 100644 index 0000000000000000000000000000000000000000..b98f4214d0e7a4c430b88c63c964b85b79a72acc --- /dev/null +++ b/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) diff --git a/test/property/generators/message.rb b/test/property/generators/message.rb new file mode 100644 index 0000000000000000000000000000000000000000..2c05d0a61b5c6f8190256252f808cb42f5ac8362 --- /dev/null +++ b/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 diff --git a/test/property/generators/test_message.rb b/test/property/generators/test_message.rb new file mode 100644 index 0000000000000000000000000000000000000000..6f1b1320246195ab6f3858f6c18492facc187818 --- /dev/null +++ b/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 diff --git a/test/property/generators/webhook.rb b/test/property/generators/webhook.rb new file mode 100644 index 0000000000000000000000000000000000000000..daf042a041b87cf4cf9713c7c94de99b15f6f37d --- /dev/null +++ b/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 diff --git a/test/property/rantly_extensions/data_extensions.rb b/test/property/rantly_extensions/data_extensions.rb new file mode 100644 index 0000000000000000000000000000000000000000..97ecd83e2124d644ee5cdf1918e8c9778a49ec58 --- /dev/null +++ b/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