diff --git a/.circleci/config.yml b/.circleci/config.yml index 5c446bb1..c5deb9e1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,10 +21,14 @@ workflows: - build-test-linux: name: Ruby 3.1 docker-image: cimg/ruby:3.1 + - build-test-linux: + name: Ruby 3.2 + docker-image: cimg/ruby:3.2 - build-test-linux: name: JRuby 9.4 docker-image: jruby:9.4-jdk jruby: true + spec-tags: -t '~flaky' jobs: build-test-windows: @@ -37,6 +41,12 @@ jobs: steps: - checkout + # No idea what this is. But the win orb starts it up on port 8000 which + # conflicts with DynamoDB. So we will stop it. + - run: + name: "Shutdown IBXDashboard" + command: Stop-Service IBXDashboard + - run: name: "Setup DynamoDB" command: | @@ -97,10 +107,13 @@ jobs: jruby: type: boolean default: false + spec-tags: + type: string + default: "" docker: - image: <> - - image: consul + - image: hashicorp/consul - image: redis - image: amazon/dynamodb-local @@ -124,7 +137,7 @@ jobs: - run: gem install bundler -v 2.2.33 - run: bundle _2.2.33_ install - run: mkdir /tmp/circle-artifacts - - run: bundle _2.2.33_ exec rspec --format documentation --format RspecJunitFormatter -o /tmp/circle-artifacts/rspec.xml spec + - run: bundle _2.2.33_ exec rspec --format documentation --format RspecJunitFormatter -o /tmp/circle-artifacts/rspec.xml spec <> - run: mv coverage /tmp/circle-artifacts/ - when: diff --git a/.rubocop.yml b/.rubocop.yml index fe9b24ab..cbfb56b3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -170,7 +170,7 @@ Style/FormatString: Style/GlobalVars: Description: 'Do not introduce global variables.' StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#instance-vars' - Reference: 'http://www.zenspider.com/Languages/Ruby/QuickRef.html' + Reference: 'https://www.zenspider.com/ruby/quickref.html' Enabled: false Style/GuardClause: diff --git a/CODEOWNERS b/CODEOWNERS index 8b137891..71f97d0a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1,2 @@ - +# Repository Maintainers +* @launchdarkly/team-sdk-ruby diff --git a/README.md b/README.md index 87cbc5f1..8fe7b9ea 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ LaunchDarkly Server-side SDK for Ruby LaunchDarkly overview ------------------------- -[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! [![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) @@ -26,7 +26,7 @@ Refer to the [SDK documentation](https://docs.launchdarkly.com/sdk/server-side/r Learn more ----------- -Check out our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [reference guide for this SDK](http://docs.launchdarkly.com/docs/ruby-sdk-reference). +Read our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [reference guide for this SDK](http://docs.launchdarkly.com/docs/ruby-sdk-reference). Generated API documentation for all versions of the SDK is on [RubyDoc.info](https://www.rubydoc.info/gems/launchdarkly-server-sdk). The API documentation for the latest version is also on [GitHub Pages](https://launchdarkly.github.io/ruby-server-sdk). diff --git a/lib/ldclient-rb/context.rb b/lib/ldclient-rb/context.rb index 6941e521..bd5a81c3 100644 --- a/lib/ldclient-rb/context.rb +++ b/lib/ldclient-rb/context.rb @@ -317,6 +317,8 @@ def self.with_key(key, kind = KIND_DEFAULT) # {https://docs.launchdarkly.com/sdk/features/user-config SDK # documentation}. # + # @deprecated The old user format will be removed in 8.0.0. Please use the new context specific format. + # # @param data [Hash] # @return [LDContext] # @@ -397,6 +399,8 @@ def self.create_multi(contexts) # @return [LDContext] # private_class_method def self.create_legacy_context(data) + warn("DEPRECATED: legacy user format will be removed in 8.0.0", uplevel: 1) + key = data[:key] # Legacy users are allowed to have "" as a key but they cannot have nil as a key. diff --git a/lib/ldclient-rb/impl/context.rb b/lib/ldclient-rb/impl/context.rb index e1b9e7a0..8ff6820b 100644 --- a/lib/ldclient-rb/impl/context.rb +++ b/lib/ldclient-rb/impl/context.rb @@ -40,7 +40,7 @@ def self.validate_kind(kind) return ERR_KIND_NON_STRING unless kind.is_a?(String) return ERR_KIND_CANNOT_BE_KIND if kind == "kind" return ERR_KIND_CANNOT_BE_MULTI if kind == "multi" - return ERR_KIND_INVALID_CHARS unless kind.match?(/^[\w.-]+$/) + ERR_KIND_INVALID_CHARS unless kind.match?(/^[\w.-]+$/) end # @@ -51,7 +51,7 @@ def self.validate_kind(kind) # def self.validate_key(key) return ERR_KEY_NON_STRING unless key.is_a?(String) - return ERR_KEY_EMPTY if key == "" + ERR_KEY_EMPTY if key == "" end # @@ -61,7 +61,7 @@ def self.validate_key(key) # @return [String, nil] # def self.validate_name(name) - return ERR_NAME_NON_STRING unless name.nil? || name.is_a?(String) + ERR_NAME_NON_STRING unless name.nil? || name.is_a?(String) end # diff --git a/lib/ldclient-rb/impl/event_sender.rb b/lib/ldclient-rb/impl/event_sender.rb index 76395a1c..4f4561d6 100644 --- a/lib/ldclient-rb/impl/event_sender.rb +++ b/lib/ldclient-rb/impl/event_sender.rb @@ -64,6 +64,7 @@ def send_event_data(event_data, description, is_diagnostic) begin res_time = Time.httpdate(response.headers["date"]) rescue ArgumentError + # Ignored end end return EventSenderResult.new(true, false, res_time) diff --git a/lib/ldclient-rb/impl/integrations/file_data_source.rb b/lib/ldclient-rb/impl/integrations/file_data_source.rb index 60c0d9c6..0491322f 100644 --- a/lib/ldclient-rb/impl/integrations/file_data_source.rb +++ b/lib/ldclient-rb/impl/integrations/file_data_source.rb @@ -18,6 +18,7 @@ class FileDataSourceImpl require 'listen' @@have_listen = true rescue LoadError + # Ignored end # diff --git a/lib/ldclient-rb/impl/integrations/redis_impl.rb b/lib/ldclient-rb/impl/integrations/redis_impl.rb index ccdf388e..cb7fb6c3 100644 --- a/lib/ldclient-rb/impl/integrations/redis_impl.rb +++ b/lib/ldclient-rb/impl/integrations/redis_impl.rb @@ -1,3 +1,4 @@ +require "ldclient-rb/interfaces" require "concurrent/atomics" require "json" @@ -117,7 +118,7 @@ def initialize(opts) @logger = opts[:logger] || Config.default_logger @test_hook = opts[:test_hook] # used for unit tests, deliberately undocumented - @stopped = Concurrent::AtomicBoolean.new() + @stopped = Concurrent::AtomicBoolean.new with_connection do |redis| @logger.info("#{description}: using Redis instance at #{redis.connection[:host]}:#{redis.connection[:port]} and prefix: #{@prefix}") diff --git a/lib/ldclient-rb/integrations/test_data.rb b/lib/ldclient-rb/integrations/test_data.rb index 3a810507..aa2ee107 100644 --- a/lib/ldclient-rb/integrations/test_data.rb +++ b/lib/ldclient-rb/integrations/test_data.rb @@ -90,7 +90,7 @@ def call(_, config) # def flag(key) existing_builder = @lock.with_read_lock { @flag_builders[key] } - if existing_builder.nil? then + if existing_builder.nil? FlagBuilder.new(key).boolean_flag else existing_builder.clone @@ -118,7 +118,7 @@ def update(flag_builder) @flag_builders[flag_builder.key] = flag_builder version = 0 flag_key = flag_builder.key.to_sym - if @current_flags[flag_key] then + if @current_flags[flag_key] version = @current_flags[flag_key][:version] end new_flag = Impl::Model.deserialize(FEATURES, flag_builder.build(version+1)) @@ -175,7 +175,7 @@ def use_preconfigured_segment(segment) key = item.key.to_sym @lock.with_write_lock do old_item = current[key] - unless old_item.nil? then + unless old_item.nil? data = item.as_json data[:version] = old_item.version + 1 item = Impl::Model.deserialize(kind, data) diff --git a/spec/big_segment_store_spec_base.rb b/spec/big_segment_store_spec_base.rb index b7c627f1..f58d5ca4 100644 --- a/spec/big_segment_store_spec_base.rb +++ b/spec/big_segment_store_spec_base.rb @@ -82,7 +82,7 @@ def with_empty_store it "includes only" do with_empty_store do |store| - store_tester.set_big_segments(fake_context_hash, ["key1", "key2"], []) + store_tester.set_big_segments(fake_context_hash, %w[key1 key2], []) membership = store.get_membership(fake_context_hash) expect(membership).to eq({ "key1" => true, "key2" => true }) @@ -91,7 +91,7 @@ def with_empty_store it "excludes only" do with_empty_store do |store| - store_tester.set_big_segments(fake_context_hash, [], ["key1", "key2"]) + store_tester.set_big_segments(fake_context_hash, [], %w[key1 key2]) membership = store.get_membership(fake_context_hash) expect(membership).to eq({ "key1" => false, "key2" => false }) @@ -100,7 +100,7 @@ def with_empty_store it "includes and excludes" do with_empty_store do |store| - store_tester.set_big_segments(fake_context_hash, ["key1", "key2"], ["key2", "key3"]) + store_tester.set_big_segments(fake_context_hash, %w[key1 key2], %w[key2 key3]) membership = store.get_membership(fake_context_hash) expect(membership).to eq({ "key1" => true, "key2" => true, "key3" => false }) # include of key2 overrides exclude diff --git a/spec/config_spec.rb b/spec/config_spec.rb index 2196bcad..5a1c2522 100644 --- a/spec/config_spec.rb +++ b/spec/config_spec.rb @@ -1,122 +1,124 @@ require "spec_helper" -describe LaunchDarkly::Config do - subject { LaunchDarkly::Config } - describe ".initialize" do - it "can be initialized with default settings" do - expect(subject).to receive(:default_capacity).and_return 1234 - expect(subject.new.capacity).to eq 1234 - end - it "accepts custom arguments" do - expect(subject).to_not receive(:default_capacity) - expect(subject.new(capacity: 50).capacity).to eq 50 - end - it "will chomp base_url and stream_uri" do - uri = "https://test.launchdarkly.com" - config = subject.new(base_uri: uri + "/") - expect(config.base_uri).to eq uri - end - end - describe "@base_uri" do - it "can be read" do - expect(subject.new.base_uri).to eq subject.default_base_uri - end - end - describe "@events_uri" do - it "can be read" do - expect(subject.new.events_uri).to eq subject.default_events_uri - end - end - describe "@stream_uri" do - it "can be read" do - expect(subject.new.stream_uri).to eq subject.default_stream_uri +module LaunchDarkly + describe Config do + subject { Config } + describe ".initialize" do + it "can be initialized with default settings" do + expect(subject).to receive(:default_capacity).and_return 1234 + expect(subject.new.capacity).to eq 1234 + end + it "accepts custom arguments" do + expect(subject).to_not receive(:default_capacity) + expect(subject.new(capacity: 50).capacity).to eq 50 + end + it "will chomp base_url and stream_uri" do + uri = "https://test.launchdarkly.com" + config = subject.new(base_uri: uri + "/") + expect(config.base_uri).to eq uri + end end - end - describe ".default_cache_store" do - it "uses Rails cache if it is available" do - rails = instance_double("Rails", cache: :cache) - stub_const("Rails", rails) - expect(subject.default_cache_store).to eq :cache + describe "@base_uri" do + it "can be read" do + expect(subject.new.base_uri).to eq subject.default_base_uri + end end - it "uses memory store if Rails is not available" do - expect(subject.default_cache_store).to be_an_instance_of LaunchDarkly::ThreadSafeMemoryStore + describe "@events_uri" do + it "can be read" do + expect(subject.new.events_uri).to eq subject.default_events_uri + end end - end - describe ".default_logger" do - it "uses Rails logger if it is available" do - rails = instance_double("Rails", logger: :logger) - stub_const("Rails", rails) - expect(subject.default_logger).to eq :logger + describe "@stream_uri" do + it "can be read" do + expect(subject.new.stream_uri).to eq subject.default_stream_uri + end end - it "Uses logger if Rails is not available" do - expect(subject.default_logger).to be_an_instance_of Logger + describe ".default_cache_store" do + it "uses Rails cache if it is available" do + rails = instance_double("Rails", cache: :cache) + stub_const("Rails", rails) + expect(subject.default_cache_store).to eq :cache + end + it "uses memory store if Rails is not available" do + expect(subject.default_cache_store).to be_an_instance_of ThreadSafeMemoryStore + end end - end - describe ".poll_interval" do - it "can be set to greater than the default" do - expect(subject.new(poll_interval: 31).poll_interval).to eq 31 + describe ".default_logger" do + it "uses Rails logger if it is available" do + rails = instance_double("Rails", logger: :logger) + stub_const("Rails", rails) + expect(subject.default_logger).to eq :logger + end + it "Uses logger if Rails is not available" do + expect(subject.default_logger).to be_an_instance_of Logger + end end - it "cannot be set to less than the default" do - expect(subject.new(poll_interval: 29).poll_interval).to eq 30 + describe ".poll_interval" do + it "can be set to greater than the default" do + expect(subject.new(poll_interval: 31).poll_interval).to eq 31 + end + it "cannot be set to less than the default" do + expect(subject.new(poll_interval: 29).poll_interval).to eq 30 + end end - end - describe ".application" do - it "can be set and read" do - app = { id: "my-id", version: "abcdef" } - expect(subject.new(application: app).application).to eq app - end + describe ".application" do + it "can be set and read" do + app = { id: "my-id", version: "abcdef" } + expect(subject.new(application: app).application).to eq app + end - it "can handle non-string values" do - expect(subject.new(application: { id: 1, version: 2 }).application).to eq ({ id: "1", version: "2" }) - end + it "can handle non-string values" do + expect(subject.new(application: { id: 1, version: 2 }).application).to eq({ id: "1", version: "2" }) + end - it "will ignore invalid keys" do - expect(subject.new(application: { invalid: 1, hashKey: 2 }).application).to eq ({ id: "", version: "" }) - end + it "will ignore invalid keys" do + expect(subject.new(application: { invalid: 1, hashKey: 2 }).application).to eq({ id: "", version: "" }) + end - it "will drop invalid values" do - [" ", "@", ":", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-a"].each do |value| - expect(subject.new(logger: $null_log, application: { id: value, version: value }).application).to eq ({ id: "", version: "" }) + it "will drop invalid values" do + [" ", "@", ":", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-a"].each do |value| + expect(subject.new(logger: $null_log, application: { id: value, version: value }).application).to eq({ id: "", version: "" }) + end end - end - it "will generate correct header tag value" do - [ - { :id => "id", :version => "version", :expected => "application-id/id application-version/version" }, - { :id => "id", :version => "", :expected => "application-id/id" }, - { :id => "", :version => "version", :expected => "application-version/version" }, - { :id => "", :version => "", :expected => "" }, - ].each do |test_case| - config = subject.new(application: { id: test_case[:id], version: test_case[:version] }) - expect(LaunchDarkly::Impl::Util.application_header_value(config.application)).to eq test_case[:expected] + it "will generate correct header tag value" do + [ + { :id => "id", :version => "version", :expected => "application-id/id application-version/version" }, + { :id => "id", :version => "", :expected => "application-id/id" }, + { :id => "", :version => "version", :expected => "application-version/version" }, + { :id => "", :version => "", :expected => "" }, + ].each do |test_case| + config = subject.new(application: { id: test_case[:id], version: test_case[:version] }) + expect(Impl::Util.application_header_value(config.application)).to eq test_case[:expected] + end end end - end - describe "context and user aliases" do - it "default values are aliased correctly" do - expect(LaunchDarkly::Config.default_context_keys_capacity).to eq LaunchDarkly::Config.default_user_keys_capacity - expect(LaunchDarkly::Config.default_context_keys_flush_interval).to eq LaunchDarkly::Config.default_user_keys_flush_interval - end + describe "context and user aliases" do + it "default values are aliased correctly" do + expect(Config.default_context_keys_capacity).to eq Config.default_user_keys_capacity + expect(Config.default_context_keys_flush_interval).to eq Config.default_user_keys_flush_interval + end - it "context options are reflected in user options" do - config = subject.new(context_keys_capacity: 50, context_keys_flush_interval: 25) - expect(config.context_keys_capacity).to eq config.user_keys_capacity - expect(config.context_keys_flush_interval).to eq config.user_keys_flush_interval - end + it "context options are reflected in user options" do + config = subject.new(context_keys_capacity: 50, context_keys_flush_interval: 25) + expect(config.context_keys_capacity).to eq config.user_keys_capacity + expect(config.context_keys_flush_interval).to eq config.user_keys_flush_interval + end - it "context options can be set by user options" do - config = subject.new(user_keys_capacity: 50, user_keys_flush_interval: 25) - expect(config.context_keys_capacity).to eq config.user_keys_capacity - expect(config.context_keys_flush_interval).to eq config.user_keys_flush_interval - end + it "context options can be set by user options" do + config = subject.new(user_keys_capacity: 50, user_keys_flush_interval: 25) + expect(config.context_keys_capacity).to eq config.user_keys_capacity + expect(config.context_keys_flush_interval).to eq config.user_keys_flush_interval + end - it "context options take precedence" do - config = subject.new(context_keys_capacity: 100, user_keys_capacity: 50, context_keys_flush_interval: 100, user_keys_flush_interval: 50) + it "context options take precedence" do + config = subject.new(context_keys_capacity: 100, user_keys_capacity: 50, context_keys_flush_interval: 100, user_keys_flush_interval: 50) - expect(config.context_keys_capacity).to eq 100 - expect(config.context_keys_flush_interval).to eq 100 + expect(config.context_keys_capacity).to eq 100 + expect(config.context_keys_flush_interval).to eq 100 + end end end end diff --git a/spec/context_spec.rb b/spec/context_spec.rb index ae1c9cfd..7ac83d6a 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -1,367 +1,369 @@ require "ldclient-rb/context" -describe LaunchDarkly::LDContext do - subject { LaunchDarkly::LDContext } +module LaunchDarkly + describe LDContext do + subject { LDContext } - it "returns nil for any value if invalid" do - result = subject.create({ key: "", kind: "user", name: "testing" }) + it "returns nil for any value if invalid" do + result = subject.create({ key: "", kind: "user", name: "testing" }) - expect(result.valid?).to be false + expect(result.valid?).to be false - expect(result.key).to be_nil - expect(result.get_value(:key)).to be_nil + expect(result.key).to be_nil + expect(result.get_value(:key)).to be_nil - expect(result.kind).to be_nil - expect(result.get_value(:kind)).to be_nil + expect(result.kind).to be_nil + expect(result.get_value(:kind)).to be_nil - expect(result.get_value(:name)).to be_nil - end + expect(result.get_value(:name)).to be_nil + end - describe "context construction" do - describe "legacy users contexts" do - it "can be created using the legacy user format" do - context = { - key: "user-key", - custom: { - address: { - street: "123 Main St.", - city: "Every City", - state: "XX", + describe "context construction" do + describe "legacy users contexts" do + it "can be created using the legacy user format" do + context = { + key: "user-key", + custom: { + address: { + street: "123 Main St.", + city: "Every City", + state: "XX", + }, }, - }, - } - result = subject.create(context) - expect(result).to be_a(LaunchDarkly::LDContext) - expect(result.key).to eq("user-key") - expect(result.kind).to eq("user") - expect(result.valid?).to be true - end - - it "allows an empty string for a key, but it cannot be missing or nil" do - expect(subject.create({ key: "" }).valid?).to be true - expect(subject.create({ key: nil }).valid?).to be false - expect(subject.create({}).valid?).to be false - end + } + result = subject.create(context) + expect(result).to be_a(LDContext) + expect(result.key).to eq("user-key") + expect(result.kind).to eq("user") + expect(result.valid?).to be true + end - it "anonymous is required to be a boolean or nil" do - expect(subject.create({ key: "" }).valid?).to be true - expect(subject.create({ key: "", anonymous: true }).valid?).to be true - expect(subject.create({ key: "", anonymous: false }).valid?).to be true - expect(subject.create({ key: "", anonymous: 0 }).valid?).to be false - end + it "allows an empty string for a key, but it cannot be missing or nil" do + expect(subject.create({ key: "" }).valid?).to be true + expect(subject.create({ key: nil }).valid?).to be false + expect(subject.create({}).valid?).to be false + end - it "name is required to be a string or nil" do - expect(subject.create({ key: "" }).valid?).to be true - expect(subject.create({ key: "", name: "My Name" }).valid?).to be true - expect(subject.create({ key: "", name: 0 }).valid?).to be false - end + it "anonymous is required to be a boolean or nil" do + expect(subject.create({ key: "" }).valid?).to be true + expect(subject.create({ key: "", anonymous: true }).valid?).to be true + expect(subject.create({ key: "", anonymous: false }).valid?).to be true + expect(subject.create({ key: "", anonymous: 0 }).valid?).to be false + end - it "creates the correct fully qualified key" do - expect(subject.create({ key: "user-key" }).fully_qualified_key).to eq("user-key") - end + it "name is required to be a string or nil" do + expect(subject.create({ key: "" }).valid?).to be true + expect(subject.create({ key: "", name: "My Name" }).valid?).to be true + expect(subject.create({ key: "", name: 0 }).valid?).to be false + end - it "requires privateAttributeNames to be an array" do - context = { - key: "user-key", - privateAttributeNames: "not an array", - } - expect(subject.create(context).valid?).to be false - end + it "creates the correct fully qualified key" do + expect(subject.create({ key: "user-key" }).fully_qualified_key).to eq("user-key") + end - it "overwrite custom properties with built-ins when collisions occur" do - context = { - key: "user-key", - ip: "192.168.1.1", - avatar: "avatar", - custom: { - ip: "127.0.0.1", - avatar: "custom avatar", - }, - } - - result = subject.create(context) - expect(result.get_value(:ip)).to eq("192.168.1.1") - expect(result.get_value(:avatar)).to eq("avatar") - end - end + it "requires privateAttributeNames to be an array" do + context = { + key: "user-key", + privateAttributeNames: "not an array", + } + expect(subject.create(context).valid?).to be false + end - describe "single kind contexts" do - it "can be created using the new format" do - context = { - key: "launchdarkly", - kind: "org", - address: { - street: "1999 Harrison St Suite 1100", - city: "Oakland", - state: "CA", - zip: "94612", - }, - } - result = subject.create(context) - expect(result).to be_a(LaunchDarkly::LDContext) - expect(result.key).to eq("launchdarkly") - expect(result.kind).to eq("org") - expect(result.valid?).to be true - end + it "overwrite custom properties with built-ins when collisions occur" do + context = { + key: "user-key", + ip: "192.168.1.1", + avatar: "avatar", + custom: { + ip: "127.0.0.1", + avatar: "custom avatar", + }, + } - it "do not allow empty strings or nil values for keys" do - expect(subject.create({ kind: "user", key: "" }).valid?).to be false - expect(subject.create({ kind: "user", key: nil }).valid?).to be false - expect(subject.create({ kind: "user" }).valid?).to be false + result = subject.create(context) + expect(result.get_value(:ip)).to eq("192.168.1.1") + expect(result.get_value(:avatar)).to eq("avatar") + end end - it "does not allow reserved names or empty values for kind" do - expect(subject.create({ kind: true, key: "key" }).valid?).to be false - expect(subject.create({ kind: "", key: "key" }).valid?).to be false - expect(subject.create({ kind: "kind", key: "key" }).valid?).to be false - expect(subject.create({ kind: "multi", key: "key" }).valid?).to be false - end + describe "single kind contexts" do + it "can be created using the new format" do + context = { + key: "launchdarkly", + kind: "org", + address: { + street: "1999 Harrison St Suite 1100", + city: "Oakland", + state: "CA", + zip: "94612", + }, + } + result = subject.create(context) + expect(result).to be_a(LDContext) + expect(result.key).to eq("launchdarkly") + expect(result.kind).to eq("org") + expect(result.valid?).to be true + end - it "anonymous is required to be a boolean or nil" do - expect(subject.create({ key: "key", kind: "user" }).valid?).to be true - expect(subject.create({ key: "key", kind: "user", anonymous: nil }).valid?).to be false - expect(subject.create({ key: "key", kind: "user", anonymous: true }).valid?).to be true - expect(subject.create({ key: "key", kind: "user", anonymous: false }).valid?).to be true - expect(subject.create({ key: "key", kind: "user", anonymous: 0 }).valid?).to be false - end + it "do not allow empty strings or nil values for keys" do + expect(subject.create({ kind: "user", key: "" }).valid?).to be false + expect(subject.create({ kind: "user", key: nil }).valid?).to be false + expect(subject.create({ kind: "user" }).valid?).to be false + end - it "name is required to be a string or nil" do - expect(subject.create({ key: "key", kind: "user" }).valid?).to be true - expect(subject.create({ key: "key", kind: "user", name: "My Name" }).valid?).to be true - expect(subject.create({ key: "key", kind: "user", name: 0 }).valid?).to be false - end + it "does not allow reserved names or empty values for kind" do + expect(subject.create({ kind: true, key: "key" }).valid?).to be false + expect(subject.create({ kind: "", key: "key" }).valid?).to be false + expect(subject.create({ kind: "kind", key: "key" }).valid?).to be false + expect(subject.create({ kind: "multi", key: "key" }).valid?).to be false + end - it "require privateAttributes to be an array" do - context = { - key: "user-key", - kind: "user", - _meta: { - privateAttributes: "not an array", - }, - } - expect(subject.create(context).valid?).to be false - end + it "anonymous is required to be a boolean or nil" do + expect(subject.create({ key: "key", kind: "user" }).valid?).to be true + expect(subject.create({ key: "key", kind: "user", anonymous: nil }).valid?).to be false + expect(subject.create({ key: "key", kind: "user", anonymous: true }).valid?).to be true + expect(subject.create({ key: "key", kind: "user", anonymous: false }).valid?).to be true + expect(subject.create({ key: "key", kind: "user", anonymous: 0 }).valid?).to be false + end - it "creates the correct fully qualified key" do - expect(subject.create({ key: "user-key", kind: "user" }).fully_qualified_key).to eq("user-key") - expect(subject.create({ key: "org-key", kind: "org" }).fully_qualified_key).to eq("org:org-key") - end - end + it "name is required to be a string or nil" do + expect(subject.create({ key: "key", kind: "user" }).valid?).to be true + expect(subject.create({ key: "key", kind: "user", name: "My Name" }).valid?).to be true + expect(subject.create({ key: "key", kind: "user", name: 0 }).valid?).to be false + end - describe "multi-kind contexts" do - it "can be created from single kind contexts" do - user_context = subject.create({ key: "user-key" }) - org_context = subject.create({ key: "org-key", kind: "org" }) - multi_context = subject.create_multi([user_context, org_context]) + it "require privateAttributes to be an array" do + context = { + key: "user-key", + kind: "user", + _meta: { + privateAttributes: "not an array", + }, + } + expect(subject.create(context).valid?).to be false + end - expect(multi_context).to be_a(LaunchDarkly::LDContext) - expect(multi_context.key).to be_nil - expect(multi_context.kind).to eq("multi") - expect(multi_context.valid?).to be true + it "creates the correct fully qualified key" do + expect(subject.create({ key: "user-key", kind: "user" }).fully_qualified_key).to eq("user-key") + expect(subject.create({ key: "org-key", kind: "org" }).fully_qualified_key).to eq("org:org-key") + end end - it "can be created from a hash" do - data = { kind: "multi", user_context: { key: "user-key"}, org: { key: "org-key"}} - multi_context = subject.create(data) - - expect(multi_context).to be_a(LaunchDarkly::LDContext) - expect(multi_context.key).to be_nil - expect(multi_context.kind).to eq(LaunchDarkly::LDContext::KIND_MULTI) - expect(multi_context.valid?).to be true - end + describe "multi-kind contexts" do + it "can be created from single kind contexts" do + user_context = subject.create({ key: "user-key" }) + org_context = subject.create({ key: "org-key", kind: "org" }) + multi_context = subject.create_multi([user_context, org_context]) - it "will return the single kind context if only one is provided" do - user_context = subject.create({ key: "user-key" }) - multi_context = subject.create_multi([user_context]) + expect(multi_context).to be_a(LDContext) + expect(multi_context.key).to be_nil + expect(multi_context.kind).to eq("multi") + expect(multi_context.valid?).to be true + end - expect(multi_context).to be_a(LaunchDarkly::LDContext) - expect(multi_context).to eq(user_context) - end + it "can be created from a hash" do + data = { kind: "multi", user_context: { key: "user-key"}, org: { key: "org-key"}} + multi_context = subject.create(data) - it "cannot include another multi-kind context" do - user_context = subject.create({ key: "user-key" }) - org_context = subject.create({ key: "org-key", kind: "org" }) - embedded_multi_context = subject.create_multi([user_context, org_context]) - multi_context = subject.create_multi([embedded_multi_context]) + expect(multi_context).to be_a(LDContext) + expect(multi_context.key).to be_nil + expect(multi_context.kind).to eq(LDContext::KIND_MULTI) + expect(multi_context.valid?).to be true + end - expect(multi_context).to be_a(LaunchDarkly::LDContext) - expect(multi_context.valid?).to be false - end + it "will return the single kind context if only one is provided" do + user_context = subject.create({ key: "user-key" }) + multi_context = subject.create_multi([user_context]) - it "are invalid if no contexts are provided" do - multi_context = subject.create_multi([]) - expect(multi_context.valid?).to be false - end + expect(multi_context).to be_a(LDContext) + expect(multi_context).to eq(user_context) + end - it "are invalid if a single context is invalid" do - valid_context = subject.create({ kind: "user", key: "user-key" }) - invalid_context = subject.create({ kind: "org" }) - multi_context = subject.create_multi([valid_context, invalid_context]) + it "cannot include another multi-kind context" do + user_context = subject.create({ key: "user-key" }) + org_context = subject.create({ key: "org-key", kind: "org" }) + embedded_multi_context = subject.create_multi([user_context, org_context]) + multi_context = subject.create_multi([embedded_multi_context]) - expect(valid_context.valid?).to be true - expect(invalid_context.valid?).to be false - expect(multi_context.valid?).to be false - end + expect(multi_context).to be_a(LDContext) + expect(multi_context.valid?).to be false + end - it "creates the correct fully qualified key" do - user_context = subject.create({ key: "a-user-key" }) - org_context = subject.create({ key: "b-org-key", kind: "org" }) - user_first = subject.create_multi([user_context, org_context]) - org_first = subject.create_multi([org_context, user_context]) + it "are invalid if no contexts are provided" do + multi_context = subject.create_multi([]) + expect(multi_context.valid?).to be false + end - # Verify we are sorting contexts by kind when generating the canonical key - expect(user_first.fully_qualified_key).to eq("org:b-org-key:user:a-user-key") - expect(org_first.fully_qualified_key).to eq("org:b-org-key:user:a-user-key") - end - end - end + it "are invalid if a single context is invalid" do + valid_context = subject.create({ kind: "user", key: "user-key" }) + invalid_context = subject.create({ kind: "org" }) + multi_context = subject.create_multi([valid_context, invalid_context]) - describe "context counts" do - it "invalid contexts have a size of 0" do - context = subject.create({}) + expect(valid_context.valid?).to be true + expect(invalid_context.valid?).to be false + expect(multi_context.valid?).to be false + end - expect(context.valid?).to be false - expect(context.individual_context_count).to eq(0) - end + it "creates the correct fully qualified key" do + user_context = subject.create({ key: "a-user-key" }) + org_context = subject.create({ key: "b-org-key", kind: "org" }) + user_first = subject.create_multi([user_context, org_context]) + org_first = subject.create_multi([org_context, user_context]) - it "individual contexts have a size of 1" do - context = subject.create({ kind: "user", key: "user-key" }) - expect(context.individual_context_count).to eq(1) + # Verify we are sorting contexts by kind when generating the canonical key + expect(user_first.fully_qualified_key).to eq("org:b-org-key:user:a-user-key") + expect(org_first.fully_qualified_key).to eq("org:b-org-key:user:a-user-key") + end + end end - it "multi-kind contexts have a size equal to the single-kind contexts" do - user_context = subject.create({ key: "user-key", kind: "user" }) - org_context = subject.create({ key: "org-key", kind: "org" }) - multi_context = subject.create_multi([user_context, org_context]) + describe "context counts" do + it "invalid contexts have a size of 0" do + context = subject.create({}) - expect(multi_context.individual_context_count).to eq(2) - end - end + expect(context.valid?).to be false + expect(context.individual_context_count).to eq(0) + end - describe "retrieving specific contexts" do - it "invalid contexts always return nil" do - context = subject.create({kind: "user"}) + it "individual contexts have a size of 1" do + context = subject.create({ kind: "user", key: "user-key" }) + expect(context.individual_context_count).to eq(1) + end - expect(context.valid?).to be false - expect(context.individual_context(-1)).to be_nil - expect(context.individual_context(0)).to be_nil - expect(context.individual_context(1)).to be_nil + it "multi-kind contexts have a size equal to the single-kind contexts" do + user_context = subject.create({ key: "user-key", kind: "user" }) + org_context = subject.create({ key: "org-key", kind: "org" }) + multi_context = subject.create_multi([user_context, org_context]) - expect(context.individual_context("user")).to be_nil + expect(multi_context.individual_context_count).to eq(2) + end end - it "single contexts can retrieve themselves" do - context = subject.create({key: "user-key", kind: "user"}) - - expect(context.valid?).to be true - expect(context.individual_context(-1)).to be_nil - expect(context.individual_context(0)).to eq(context) - expect(context.individual_context(1)).to be_nil - - expect(context.individual_context("user")).to eq(context) - expect(context.individual_context("org")).to be_nil - end + describe "retrieving specific contexts" do + it "invalid contexts always return nil" do + context = subject.create({kind: "user"}) - it "multi-kind contexts can return nested contexts" do - user_context = subject.create({ key: "user-key", kind: "user" }) - org_context = subject.create({ key: "org-key", kind: "org" }) - multi_context = subject.create_multi([user_context, org_context]) + expect(context.valid?).to be false + expect(context.individual_context(-1)).to be_nil + expect(context.individual_context(0)).to be_nil + expect(context.individual_context(1)).to be_nil - expect(multi_context.valid?).to be true - expect(multi_context.individual_context(-1)).to be_nil - expect(multi_context.individual_context(0)).to eq(user_context) - expect(multi_context.individual_context(1)).to eq(org_context) + expect(context.individual_context("user")).to be_nil + end - expect(multi_context.individual_context("user")).to eq(user_context) - expect(multi_context.individual_context("org")).to eq(org_context) - end - end + it "single contexts can retrieve themselves" do + context = subject.create({key: "user-key", kind: "user"}) - describe "value retrieval" do - describe "supports simple attribute retrieval" do - it "can retrieve the correct simple attribute value" do - context = subject.create({ key: "my-key", kind: "org", name: "x", :"my-attr" => "y", :"/starts-with-slash" => "z" }) + expect(context.valid?).to be true + expect(context.individual_context(-1)).to be_nil + expect(context.individual_context(0)).to eq(context) + expect(context.individual_context(1)).to be_nil - expect(context.get_value("kind")).to eq("org") - expect(context.get_value("key")).to eq("my-key") - expect(context.get_value("name")).to eq("x") - expect(context.get_value("my-attr")).to eq("y") - expect(context.get_value("/starts-with-slash")).to eq("z") + expect(context.individual_context("user")).to eq(context) + expect(context.individual_context("org")).to be_nil end - it "does not allow querying subpath/elements" do - object_value = { a: 1 } - array_value = [1] + it "multi-kind contexts can return nested contexts" do + user_context = subject.create({ key: "user-key", kind: "user" }) + org_context = subject.create({ key: "org-key", kind: "org" }) + multi_context = subject.create_multi([user_context, org_context]) - context = subject.create({ key: "my-key", kind: "org", :"obj-attr" => object_value, :"array-attr" => array_value }) - expect(context.get_value("obj-attr")).to eq(object_value) - expect(context.get_value(:"array-attr")).to eq(array_value) + expect(multi_context.valid?).to be true + expect(multi_context.individual_context(-1)).to be_nil + expect(multi_context.individual_context(0)).to eq(user_context) + expect(multi_context.individual_context(1)).to eq(org_context) - expect(context.get_value(:"/obj-attr/a")).to be_nil - expect(context.get_value(:"/array-attr/0")).to be_nil + expect(multi_context.individual_context("user")).to eq(user_context) + expect(multi_context.individual_context("org")).to eq(org_context) end end - describe "supports retrieval" do - it "with only support kind for multi-kind contexts" do - user_context = subject.create({ key: 'user', name: 'Ruby', anonymous: true }) - org_context = subject.create({ key: 'ld', kind: 'org', name: 'LaunchDarkly', anonymous: false }) - - multi_context = subject.create_multi([user_context, org_context]) + describe "value retrieval" do + describe "supports simple attribute retrieval" do + it "can retrieve the correct simple attribute value" do + context = subject.create({ key: "my-key", kind: "org", name: "x", :"my-attr" => "y", :"/starts-with-slash" => "z" }) - [ - ['kind', eq('multi')], - ['key', be_nil], - ['name', be_nil], - ['anonymous', be_nil], - ].each do |(reference, matcher)| - expect(multi_context.get_value_for_reference(LaunchDarkly::Reference.create(reference))).to matcher + expect(context.get_value("kind")).to eq("org") + expect(context.get_value("key")).to eq("my-key") + expect(context.get_value("name")).to eq("x") + expect(context.get_value("my-attr")).to eq("y") + expect(context.get_value("/starts-with-slash")).to eq("z") end - end - it "with basic attributes" do - legacy_user = subject.create({ key: 'user', name: 'Ruby', privateAttributeNames: ['name'] }) - org_context = subject.create({ key: 'ld', kind: 'org', name: 'LaunchDarkly', anonymous: true, _meta: { privateAttributes: ['name'] } }) - - [ - # Simple top level attributes are accessible - ['kind', eq('user'), eq('org')], - ['key', eq('user'), eq('ld')], - ['name', eq('Ruby'), eq('LaunchDarkly')], - ['anonymous', eq(false), eq(true)], - - # Cannot access meta data - ['privateAttributeNames', be_nil, be_nil], - ['privateAttributes', be_nil, be_nil], - ].each do |(reference, user_matcher, org_matcher)| - ref = LaunchDarkly::Reference.create(reference) - expect(legacy_user.get_value_for_reference(ref)).to user_matcher - expect(org_context.get_value_for_reference(ref)).to org_matcher - end - end + it "does not allow querying subpath/elements" do + object_value = { a: 1 } + array_value = [1] - it "with complex attributes" do - address = { city: "Oakland", state: "CA", zip: 94612 } - tags = ["LaunchDarkly", "Feature Flags"] - nested = { upper: { middle: { name: "Middle Level", inner: { levels: [0, 1, 2] } }, name: "Upper Level" } } + context = subject.create({ key: "my-key", kind: "org", :"obj-attr" => object_value, :"array-attr" => array_value }) + expect(context.get_value("obj-attr")).to eq(object_value) + expect(context.get_value(:"array-attr")).to eq(array_value) - legacy_user = subject.create({ key: 'user', name: 'Ruby', custom: { address: address, tags: tags, nested: nested }}) - org_context = subject.create({ key: 'ld', kind: 'org', name: 'LaunchDarkly', anonymous: true, address: address, tags: tags, nested: nested }) + expect(context.get_value(:"/obj-attr/a")).to be_nil + expect(context.get_value(:"/array-attr/0")).to be_nil + end + end - [ - # Simple top level attributes are accessible - ['/address', eq(address)], - ['/address/city', eq('Oakland')], + describe "supports retrieval" do + it "with only support kind for multi-kind contexts" do + user_context = subject.create({ key: 'user', name: 'Ruby', anonymous: true }) + org_context = subject.create({ key: 'ld', kind: 'org', name: 'LaunchDarkly', anonymous: false }) + + multi_context = subject.create_multi([user_context, org_context]) + + [ + ['kind', eq('multi')], + ['key', be_nil], + ['name', be_nil], + ['anonymous', be_nil], + ].each do |(reference, matcher)| + expect(multi_context.get_value_for_reference(Reference.create(reference))).to matcher + end + end - ['/tags', eq(tags)], + it "with basic attributes" do + legacy_user = subject.create({ key: 'user', name: 'Ruby', privateAttributeNames: ['name'] }) + org_context = subject.create({ key: 'ld', kind: 'org', name: 'LaunchDarkly', anonymous: true, _meta: { privateAttributes: ['name'] } }) + + [ + # Simple top level attributes are accessible + ['kind', eq('user'), eq('org')], + ['key', eq('user'), eq('ld')], + ['name', eq('Ruby'), eq('LaunchDarkly')], + ['anonymous', eq(false), eq(true)], + + # Cannot access meta data + ['privateAttributeNames', be_nil, be_nil], + ['privateAttributes', be_nil, be_nil], + ].each do |(reference, user_matcher, org_matcher)| + ref = Reference.create(reference) + expect(legacy_user.get_value_for_reference(ref)).to user_matcher + expect(org_context.get_value_for_reference(ref)).to org_matcher + end + end - ['/nested/upper/name', eq('Upper Level')], - ['/nested/upper/middle/name', eq('Middle Level')], - ['/nested/upper/middle/inner/levels', eq([0, 1, 2])], - ].each do |(reference, matcher)| - ref = LaunchDarkly::Reference.create(reference) - expect(legacy_user.get_value_for_reference(ref)).to matcher - expect(org_context.get_value_for_reference(ref)).to matcher + it "with complex attributes" do + address = { city: "Oakland", state: "CA", zip: 94612 } + tags = ["LaunchDarkly", "Feature Flags"] + nested = { upper: { middle: { name: "Middle Level", inner: { levels: [0, 1, 2] } }, name: "Upper Level" } } + + legacy_user = subject.create({ key: 'user', name: 'Ruby', custom: { address: address, tags: tags, nested: nested }}) + org_context = subject.create({ key: 'ld', kind: 'org', name: 'LaunchDarkly', anonymous: true, address: address, tags: tags, nested: nested }) + + [ + # Simple top level attributes are accessible + ['/address', eq(address)], + ['/address/city', eq('Oakland')], + + ['/tags', eq(tags)], + + ['/nested/upper/name', eq('Upper Level')], + ['/nested/upper/middle/name', eq('Middle Level')], + ['/nested/upper/middle/inner/levels', eq([0, 1, 2])], + ].each do |(reference, matcher)| + ref = Reference.create(reference) + expect(legacy_user.get_value_for_reference(ref)).to matcher + expect(org_context.get_value_for_reference(ref)).to matcher + end end end end diff --git a/spec/diagnostic_events_spec.rb b/spec/diagnostic_events_spec.rb index 786e3764..09734570 100644 --- a/spec/diagnostic_events_spec.rb +++ b/spec/diagnostic_events_spec.rb @@ -77,7 +77,7 @@ def expected_default_config end end - ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY'].each do |name| + %w[http_proxy https_proxy HTTP_PROXY HTTPS_PROXY].each do |name| it "detects proxy #{name}" do begin ENV[name] = 'http://my-proxy' @@ -91,7 +91,7 @@ def expected_default_config it "has expected SDK data" do event = default_acc.create_init_event(Config.new) - expect(event[:sdk]).to eq ({ + expect(event[:sdk]).to eq({ name: 'ruby-server-sdk', version: LaunchDarkly::VERSION, }) @@ -99,7 +99,7 @@ def expected_default_config it "has expected SDK data with wrapper" do event = default_acc.create_init_event(Config.new(wrapper_name: 'my-wrapper', wrapper_version: '2.0')) - expect(event[:sdk]).to eq ({ + expect(event[:sdk]).to eq({ name: 'ruby-server-sdk', version: LaunchDarkly::VERSION, wrapperName: 'my-wrapper', @@ -109,7 +109,7 @@ def expected_default_config it "has expected platform data" do event = default_acc.create_init_event(Config.new) - expect(event[:platform]).to include ({ + expect(event[:platform]).to include({ name: 'ruby', }) end @@ -143,13 +143,13 @@ def expected_default_config acc.record_stream_init(1000, false, 2000) event1 = acc.create_periodic_event_and_reset(2, 3, 4) event2 = acc.create_periodic_event_and_reset(5, 6, 7) - expect(event1).to include ({ + expect(event1).to include({ droppedEvents: 2, deduplicatedUsers: 3, eventsInLastBatch: 4, streamInits: [{ timestamp: 1000, failed: false, durationMillis: 2000 }], }) - expect(event2).to include ({ + expect(event2).to include({ dataSinceDate: event1[:creationDate], droppedEvents: 5, deduplicatedUsers: 6, diff --git a/spec/events_spec.rb b/spec/events_spec.rb index 17a23eb4..d01c2714 100644 --- a/spec/events_spec.rb +++ b/spec/events_spec.rb @@ -5,487 +5,487 @@ require "spec_helper" require "time" -describe LaunchDarkly::EventProcessor do - subject { LaunchDarkly::EventProcessor } - - let(:starting_timestamp) { 1000 } - let(:default_config_opts) { { diagnostic_opt_out: true, logger: $null_log } } - let(:default_config) { LaunchDarkly::Config.new(default_config_opts) } - let(:context) { LaunchDarkly::LDContext.create({ kind: "user", key: "userkey", name: "Red" }) } - - def with_processor_and_sender(config) - sender = FakeEventSender.new - timestamp = starting_timestamp - ep = subject.new("sdk_key", config, nil, nil, { - event_sender: sender, - timestamp_fn: proc { - t = timestamp - timestamp += 1 - t - }, - }) - begin - yield ep, sender - ensure - ep.stop +module LaunchDarkly + describe EventProcessor do + subject { EventProcessor } + + let(:starting_timestamp) { 1000 } + let(:default_config_opts) { { diagnostic_opt_out: true, logger: $null_log } } + let(:default_config) { Config.new(default_config_opts) } + let(:context) { LDContext.create({ kind: "user", key: "userkey", name: "Red" }) } + + def with_processor_and_sender(config) + sender = FakeEventSender.new + timestamp = starting_timestamp + ep = subject.new("sdk_key", config, nil, nil, { + event_sender: sender, + timestamp_fn: proc { + t = timestamp + timestamp += 1 + t + }, + }) + begin + yield ep, sender + ensure + ep.stop + end end - end - it "queues identify event" do - with_processor_and_sender(default_config) do |ep, sender| - ep.record_identify_event(context) + it "queues identify event" do + with_processor_and_sender(default_config) do |ep, sender| + ep.record_identify_event(context) - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly(eq(identify_event(default_config, context))) + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly(eq(identify_event(default_config, context))) + end end - end - it "filters context in identify event" do - config = LaunchDarkly::Config.new(default_config_opts.merge(all_attributes_private: true)) - with_processor_and_sender(config) do |ep, sender| - ep.record_identify_event(context) + it "filters context in identify event" do + config = Config.new(default_config_opts.merge(all_attributes_private: true)) + with_processor_and_sender(config) do |ep, sender| + ep.record_identify_event(context) - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly(eq(identify_event(config, context))) + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly(eq(identify_event(config, context))) + end end - end - it "queues individual feature event with index event" do - with_processor_and_sender(default_config) do |ep, sender| - flag = { key: "flagkey", version: 11 } - ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, true) - - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(default_config, context)), - eq(feature_event(flag, context, 1, 'value')), - include(:kind => "summary") - ) + it "queues individual feature event with index event" do + with_processor_and_sender(default_config) do |ep, sender| + flag = { key: "flagkey", version: 11 } + ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, true) + + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(default_config, context)), + eq(feature_event(flag, context, 1, 'value')), + include(:kind => "summary") + ) + end end - end - it "filters context in index event" do - config = LaunchDarkly::Config.new(default_config_opts.merge(all_attributes_private: true)) - with_processor_and_sender(config) do |ep, sender| - flag = { key: "flagkey", version: 11 } - ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, true) - - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(config, context)), - eq(feature_event(flag, context, 1, 'value')), - include(:kind => "summary") - ) + it "filters context in index event" do + config = Config.new(default_config_opts.merge(all_attributes_private: true)) + with_processor_and_sender(config) do |ep, sender| + flag = { key: "flagkey", version: 11 } + ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, true) + + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(config, context)), + eq(feature_event(flag, context, 1, 'value')), + include(:kind => "summary") + ) + end end - end - it "filters context in feature event" do - config = LaunchDarkly::Config.new(default_config_opts.merge(all_attributes_private: true)) - with_processor_and_sender(config) do |ep, sender| - flag = { key: "flagkey", version: 11 } - ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, true) - - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(config, context)), - eq(feature_event(flag, context, 1, 'value')), - include(:kind => "summary") - ) + it "filters context in feature event" do + config = Config.new(default_config_opts.merge(all_attributes_private: true)) + with_processor_and_sender(config) do |ep, sender| + flag = { key: "flagkey", version: 11 } + ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, true) + + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(config, context)), + eq(feature_event(flag, context, 1, 'value')), + include(:kind => "summary") + ) + end end - end - it "sets event kind to debug if flag is temporarily in debug mode" do - with_processor_and_sender(default_config) do |ep, sender| - flag = { key: "flagkey", version: 11 } - future_time = (Time.now.to_f * 1000).to_i + 1000000 - ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, false, future_time) - - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(default_config, context)), - eq(debug_event(default_config, flag, context, 1, 'value')), - include(:kind => "summary") - ) + it "sets event kind to debug if flag is temporarily in debug mode" do + with_processor_and_sender(default_config) do |ep, sender| + flag = { key: "flagkey", version: 11 } + future_time = (Time.now.to_f * 1000).to_i + 1000000 + ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, false, future_time) + + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(default_config, context)), + eq(debug_event(default_config, flag, context, 1, 'value')), + include(:kind => "summary") + ) + end end - end - it "can be both debugging and tracking an event" do - with_processor_and_sender(default_config) do |ep, sender| - flag = { key: "flagkey", version: 11 } - future_time = (Time.now.to_f * 1000).to_i + 1000000 - ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, true, future_time) - - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(default_config, context)), - eq(feature_event(flag, context, 1, 'value')), - eq(debug_event(default_config, flag, context, 1, 'value')), - include(:kind => "summary") - ) + it "can be both debugging and tracking an event" do + with_processor_and_sender(default_config) do |ep, sender| + flag = { key: "flagkey", version: 11 } + future_time = (Time.now.to_f * 1000).to_i + 1000000 + ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, true, future_time) + + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(default_config, context)), + eq(feature_event(flag, context, 1, 'value')), + eq(debug_event(default_config, flag, context, 1, 'value')), + include(:kind => "summary") + ) + end end - end - it "ends debug mode based on client time if client time is later than server time" do - with_processor_and_sender(default_config) do |ep, sender| - # Pick a server time that is somewhat behind the client time - server_time = Time.now - 20 + it "ends debug mode based on client time if client time is later than server time" do + with_processor_and_sender(default_config) do |ep, sender| + # Pick a server time that is somewhat behind the client time + server_time = Time.now - 20 - # Send and flush an event we don't care about, just to set the last server time - sender.result = LaunchDarkly::Impl::EventSenderResult.new(true, false, server_time) + # Send and flush an event we don't care about, just to set the last server time + sender.result = Impl::EventSenderResult.new(true, false, server_time) - ep.record_identify_event(context) - flush_and_get_events(ep, sender) + ep.record_identify_event(context) + flush_and_get_events(ep, sender) - # Now send an event with debug mode on, with a "debug until" time that is further in - # the future than the server time, but in the past compared to the client. - flag = { key: "flagkey", version: 11 } - debug_until = (server_time.to_f * 1000).to_i + 1000 - ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, false, debug_until) + # Now send an event with debug mode on, with a "debug until" time that is further in + # the future than the server time, but in the past compared to the client. + debug_until = (server_time.to_f * 1000).to_i + 1000 + ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, false, debug_until) - # Should get a summary event only, not a full feature event - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - include(:kind => "summary") - ) + # Should get a summary event only, not a full feature event + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + include(:kind => "summary") + ) + end end - end - it "ends debug mode based on server time if server time is later than client time" do - with_processor_and_sender(default_config) do |ep, sender| - # Pick a server time that is somewhat ahead of the client time - server_time = Time.now + 20 - - # Send and flush an event we don't care about, just to set the last server time - sender.result = LaunchDarkly::Impl::EventSenderResult.new(true, false, server_time) - ep.record_identify_event(context) - flush_and_get_events(ep, sender) - - # Now send an event with debug mode on, with a "debug until" time that is further in - # the future than the server time, but in the past compared to the client. - flag = { key: "flagkey", version: 11 } - debug_until = (server_time.to_f * 1000).to_i - 1000 - ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, false, debug_until) - - # Should get a summary event only, not a full feature event - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - include(:kind => "summary") - ) + it "ends debug mode based on server time if server time is later than client time" do + with_processor_and_sender(default_config) do |ep, sender| + # Pick a server time that is somewhat ahead of the client time + server_time = Time.now + 20 + + # Send and flush an event we don't care about, just to set the last server time + sender.result = Impl::EventSenderResult.new(true, false, server_time) + ep.record_identify_event(context) + flush_and_get_events(ep, sender) + + # Now send an event with debug mode on, with a "debug until" time that is further in + # the future than the server time, but in the past compared to the client. + debug_until = (server_time.to_f * 1000).to_i - 1000 + ep.record_eval_event(context, 'flagkey', 11, 1, 'value', nil, nil, false, debug_until) + + # Should get a summary event only, not a full feature event + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + include(:kind => "summary") + ) + end end - end - it "generates only one index event for multiple events with same context" do - with_processor_and_sender(default_config) do |ep, sender| - flag1 = { key: "flagkey1", version: 11 } - flag2 = { key: "flagkey2", version: 22 } - future_time = (Time.now.to_f * 1000).to_i + 1000000 - ep.record_eval_event(context, 'flagkey1', 11, 1, 'value', nil, nil, true) - ep.record_eval_event(context, 'flagkey2', 22, 1, 'value', nil, nil, true) - - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(default_config, context)), - eq(feature_event(flag1, context, 1, 'value', starting_timestamp)), - eq(feature_event(flag2, context, 1, 'value', starting_timestamp + 1)), - include(:kind => "summary") - ) + it "generates only one index event for multiple events with same context" do + with_processor_and_sender(default_config) do |ep, sender| + flag1 = { key: "flagkey1", version: 11 } + flag2 = { key: "flagkey2", version: 22 } + + ep.record_eval_event(context, 'flagkey1', 11, 1, 'value', nil, nil, true) + ep.record_eval_event(context, 'flagkey2', 22, 1, 'value', nil, nil, true) + + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(default_config, context)), + eq(feature_event(flag1, context, 1, 'value', starting_timestamp)), + eq(feature_event(flag2, context, 1, 'value', starting_timestamp + 1)), + include(:kind => "summary") + ) + end end - end - it "summarizes non-tracked events" do - with_processor_and_sender(default_config) do |ep, sender| - ep.record_eval_event(context, 'flagkey1', 11, 1, 'value1', nil, 'default1', false) - ep.record_eval_event(context, 'flagkey2', 22, 2, 'value2', nil, 'default2', false) - - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(default_config, context)), - eq({ - kind: "summary", - startDate: starting_timestamp, - endDate: starting_timestamp + 1, - features: { - flagkey1: { - contextKinds: ["user"], - default: "default1", - counters: [ - { version: 11, variation: 1, value: "value1", count: 1 }, - ], - }, - flagkey2: { - contextKinds: ["user"], - default: "default2", - counters: [ - { version: 22, variation: 2, value: "value2", count: 1 }, - ], + it "summarizes non-tracked events" do + with_processor_and_sender(default_config) do |ep, sender| + ep.record_eval_event(context, 'flagkey1', 11, 1, 'value1', nil, 'default1', false) + ep.record_eval_event(context, 'flagkey2', 22, 2, 'value2', nil, 'default2', false) + + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(default_config, context)), + eq({ + kind: "summary", + startDate: starting_timestamp, + endDate: starting_timestamp + 1, + features: { + flagkey1: { + contextKinds: ["user"], + default: "default1", + counters: [ + { version: 11, variation: 1, value: "value1", count: 1 }, + ], + }, + flagkey2: { + contextKinds: ["user"], + default: "default2", + counters: [ + { version: 22, variation: 2, value: "value2", count: 1 }, + ], + }, }, - }, - }) - ) + }) + ) + end end - end - it "queues custom event with context" do - with_processor_and_sender(default_config) do |ep, sender| - ep.record_custom_event(context, 'eventkey', { thing: 'stuff' }, 1.5) + it "queues custom event with context" do + with_processor_and_sender(default_config) do |ep, sender| + ep.record_custom_event(context, 'eventkey', { thing: 'stuff' }, 1.5) - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(default_config, context)), - eq(custom_event(context, 'eventkey', { thing: 'stuff' }, 1.5)) - ) + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(default_config, context)), + eq(custom_event(context, 'eventkey', { thing: 'stuff' }, 1.5)) + ) + end end - end - it "filters context in custom event" do - config = LaunchDarkly::Config.new(default_config_opts.merge(all_attributes_private: true)) - with_processor_and_sender(config) do |ep, sender| - ep.record_custom_event(context, 'eventkey') + it "filters context in custom event" do + config = Config.new(default_config_opts.merge(all_attributes_private: true)) + with_processor_and_sender(config) do |ep, sender| + ep.record_custom_event(context, 'eventkey') - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly( - eq(index_event(config, context)), - eq(custom_event(context, 'eventkey', nil, nil)) - ) + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly( + eq(index_event(config, context)), + eq(custom_event(context, 'eventkey', nil, nil)) + ) + end end - end - it "treats nil value for custom the same as an empty hash" do - with_processor_and_sender(default_config) do |ep, sender| - user_with_nil_custom = LaunchDarkly::LDContext.create({ key: "userkey", custom: nil }) - ep.record_identify_event(user_with_nil_custom) + it "treats nil value for custom the same as an empty hash" do + with_processor_and_sender(default_config) do |ep, sender| + user_with_nil_custom = LDContext.create({ key: "userkey", custom: nil }) + ep.record_identify_event(user_with_nil_custom) - output = flush_and_get_events(ep, sender) - expect(output).to contain_exactly(eq(identify_event(default_config, user_with_nil_custom))) + output = flush_and_get_events(ep, sender) + expect(output).to contain_exactly(eq(identify_event(default_config, user_with_nil_custom))) + end end - end - - it "does a final flush when shutting down" do - with_processor_and_sender(default_config) do |ep, sender| - ep.record_identify_event(context) - ep.stop + it "does a final flush when shutting down" do + with_processor_and_sender(default_config) do |ep, sender| + ep.record_identify_event(context) - output = sender.analytics_payloads.pop - expect(output).to contain_exactly(eq(identify_event(default_config, context))) - end - end + ep.stop - it "sends nothing if there are no events" do - with_processor_and_sender(default_config) do |ep, sender| - ep.flush - ep.wait_until_inactive - expect(sender.analytics_payloads.empty?).to be true + output = sender.analytics_payloads.pop + expect(output).to contain_exactly(eq(identify_event(default_config, context))) + end end - end - - it "stops posting events after unrecoverable error" do - with_processor_and_sender(default_config) do |ep, sender| - sender.result = LaunchDarkly::Impl::EventSenderResult.new(false, true, nil) - e = ep.record_identify_event(context) - flush_and_get_events(ep, sender) - ep.record_identify_event(context) - ep.flush - ep.wait_until_inactive - expect(sender.analytics_payloads.empty?).to be true + it "sends nothing if there are no events" do + with_processor_and_sender(default_config) do |ep, sender| + ep.flush + ep.wait_until_inactive + expect(sender.analytics_payloads.empty?).to be true + end end - end - describe "diagnostic events" do - let(:default_id) { LaunchDarkly::Impl::DiagnosticAccumulator.create_diagnostic_id('sdk_key') } - let(:diagnostic_config) { LaunchDarkly::Config.new(diagnostic_opt_out: false, logger: $null_log) } + it "stops posting events after unrecoverable error" do + with_processor_and_sender(default_config) do |ep, sender| + sender.result = Impl::EventSenderResult.new(false, true, nil) + ep.record_identify_event(context) + flush_and_get_events(ep, sender) - def with_diagnostic_processor_and_sender(config) - sender = FakeEventSender.new - acc = LaunchDarkly::Impl::DiagnosticAccumulator.new(default_id) - ep = subject.new("sdk_key", config, nil, acc, - { diagnostic_recording_interval: 0.2, event_sender: sender }) - begin - yield ep, sender - ensure - ep.stop + ep.record_identify_event(context) + ep.flush + ep.wait_until_inactive + expect(sender.analytics_payloads.empty?).to be true end end - it "sends init event" do - with_diagnostic_processor_and_sender(diagnostic_config) do |ep, sender| - event = sender.diagnostic_payloads.pop - expect(event).to include({ - kind: 'diagnostic-init', - id: default_id, - }) + describe "diagnostic events" do + let(:default_id) { Impl::DiagnosticAccumulator.create_diagnostic_id('sdk_key') } + let(:diagnostic_config) { Config.new(diagnostic_opt_out: false, logger: $null_log) } + + def with_diagnostic_processor_and_sender(config) + sender = FakeEventSender.new + acc = Impl::DiagnosticAccumulator.new(default_id) + ep = subject.new("sdk_key", config, nil, acc, + { diagnostic_recording_interval: 0.2, event_sender: sender }) + begin + yield ep, sender + ensure + ep.stop + end end - end - it "sends periodic event" do - with_diagnostic_processor_and_sender(diagnostic_config) do |ep, sender| - init_event = sender.diagnostic_payloads.pop - periodic_event = sender.diagnostic_payloads.pop - expect(periodic_event).to include({ - kind: 'diagnostic', - id: default_id, - droppedEvents: 0, - deduplicatedUsers: 0, - eventsInLastBatch: 0, - streamInits: [], - }) + it "sends init event" do + with_diagnostic_processor_and_sender(diagnostic_config) do |_, sender| + event = sender.diagnostic_payloads.pop + expect(event).to include({ + kind: 'diagnostic-init', + id: default_id, + }) + end end - end - - it "counts events in queue from last flush and dropped events" do - config = LaunchDarkly::Config.new(diagnostic_opt_out: false, capacity: 2, logger: $null_log) - with_diagnostic_processor_and_sender(config) do |ep, sender| - init_event = sender.diagnostic_payloads.pop - 3.times do - ep.record_identify_event(context) + it "sends periodic event" do + with_diagnostic_processor_and_sender(diagnostic_config) do |_, sender| + sender.diagnostic_payloads.pop + periodic_event = sender.diagnostic_payloads.pop + expect(periodic_event).to include({ + kind: 'diagnostic', + id: default_id, + droppedEvents: 0, + deduplicatedUsers: 0, + eventsInLastBatch: 0, + streamInits: [], + }) end - flush_and_get_events(ep, sender) + end - periodic_event = sender.diagnostic_payloads.pop - expect(periodic_event).to include({ - kind: 'diagnostic', - droppedEvents: 1, - eventsInLastBatch: 2, - }) + it "counts events in queue from last flush and dropped events" do + config = Config.new(diagnostic_opt_out: false, capacity: 2, logger: $null_log) + with_diagnostic_processor_and_sender(config) do |ep, sender| + sender.diagnostic_payloads.pop + + 3.times do + ep.record_identify_event(context) + end + flush_and_get_events(ep, sender) + + periodic_event = sender.diagnostic_payloads.pop + expect(periodic_event).to include({ + kind: 'diagnostic', + droppedEvents: 1, + eventsInLastBatch: 2, + }) + end end - end - it "counts deduplicated contexts" do - with_diagnostic_processor_and_sender(diagnostic_config) do |ep, sender| - sender.diagnostic_payloads.pop + it "counts deduplicated contexts" do + with_diagnostic_processor_and_sender(diagnostic_config) do |ep, sender| + sender.diagnostic_payloads.pop - ep.record_custom_event(context, 'event1') - ep.record_custom_event(context, 'event2') - flush_and_get_events(ep, sender) + ep.record_custom_event(context, 'event1') + ep.record_custom_event(context, 'event2') + flush_and_get_events(ep, sender) - periodic_event = sender.diagnostic_payloads.pop - expect(periodic_event).to include({ - kind: 'diagnostic', - deduplicatedUsers: 1, - }) + periodic_event = sender.diagnostic_payloads.pop + expect(periodic_event).to include({ + kind: 'diagnostic', + deduplicatedUsers: 1, + }) + end end end - end - # - # @param config [LaunchDarkly::Config] - # @param context [LaunchDarkly::LDContext] - # @param timestamp [Integer] - # @return [Hash] - # - def index_event(config, context, timestamp = starting_timestamp) - context_filter = LaunchDarkly::Impl::ContextFilter.new(config.all_attributes_private, config.private_attributes) - out = { - kind: "index", - creationDate: timestamp, - context: context_filter.filter(context), - } - JSON.parse(out.to_json, symbolize_names: true) - end + # + # @param config [Config] + # @param context [LDContext] + # @param timestamp [Integer] + # @return [Hash] + # + def index_event(config, context, timestamp = starting_timestamp) + context_filter = Impl::ContextFilter.new(config.all_attributes_private, config.private_attributes) + out = { + kind: "index", + creationDate: timestamp, + context: context_filter.filter(context), + } + JSON.parse(out.to_json, symbolize_names: true) + end - # - # @param config [LaunchDarkly::Config] - # @param context [LaunchDarkly::LDContext] - # @param timestamp [Integer] - # @return [Hash] - # - def identify_event(config, context, timestamp = starting_timestamp) - context_filter = LaunchDarkly::Impl::ContextFilter.new(config.all_attributes_private, config.private_attributes) - out = { - kind: "identify", - creationDate: timestamp, - key: context.fully_qualified_key, - context: context_filter.filter(context), - } - JSON.parse(out.to_json, symbolize_names: true) - end + # + # @param config [Config] + # @param context [LDContext] + # @param timestamp [Integer] + # @return [Hash] + # + def identify_event(config, context, timestamp = starting_timestamp) + context_filter = Impl::ContextFilter.new(config.all_attributes_private, config.private_attributes) + out = { + kind: "identify", + creationDate: timestamp, + key: context.fully_qualified_key, + context: context_filter.filter(context), + } + JSON.parse(out.to_json, symbolize_names: true) + end - # - # @param flag [Hash] - # @param context [LaunchDarkly::LDContext] - # @param variation [Integer] - # @param value [any] - # @param timestamp [Integer] - # @return [Hash] - # - def feature_event(flag, context, variation, value, timestamp = starting_timestamp) - out = { - kind: 'feature', - creationDate: timestamp, - contextKeys: context.keys, - key: flag[:key], - variation: variation, - version: flag[:version], - value: value, - } - JSON.parse(out.to_json, symbolize_names: true) - end + # + # @param flag [Hash] + # @param context [LDContext] + # @param variation [Integer] + # @param value [any] + # @param timestamp [Integer] + # @return [Hash] + # + def feature_event(flag, context, variation, value, timestamp = starting_timestamp) + out = { + kind: 'feature', + creationDate: timestamp, + contextKeys: context.keys, + key: flag[:key], + variation: variation, + version: flag[:version], + value: value, + } + JSON.parse(out.to_json, symbolize_names: true) + end - # - # @param config [LaunchDarkly::Config] - # @param flag [Hash] - # @param context [LaunchDarkly::LDContext] - # @param variation [Integer] - # @param value [any] - # @param timestamp [Integer] - # @return [Hash] - # - def debug_event(config, flag, context, variation, value, timestamp = starting_timestamp) - context_filter = LaunchDarkly::Impl::ContextFilter.new(config.all_attributes_private, config.private_attributes) - out = { - kind: 'debug', - creationDate: timestamp, - key: flag[:key], - variation: variation, - version: flag[:version], - value: value, - context: context_filter.filter(context), - } - JSON.parse(out.to_json, symbolize_names: true) - end + # + # @param config [Config] + # @param flag [Hash] + # @param context [LDContext] + # @param variation [Integer] + # @param value [any] + # @param timestamp [Integer] + # @return [Hash] + # + def debug_event(config, flag, context, variation, value, timestamp = starting_timestamp) + context_filter = Impl::ContextFilter.new(config.all_attributes_private, config.private_attributes) + out = { + kind: 'debug', + creationDate: timestamp, + key: flag[:key], + variation: variation, + version: flag[:version], + value: value, + context: context_filter.filter(context), + } + JSON.parse(out.to_json, symbolize_names: true) + end - # - # @param context [LaunchDarkly::LDContext] - # @param key [String] - # @param data [any] - # @param metric_value [any] - # @return [Hash] - # - def custom_event(context, key, data, metric_value) - out = { - kind: "custom", - creationDate: starting_timestamp, - contextKeys: context.keys, - key: key, - } - out[:data] = data unless data.nil? - out[:metricValue] = metric_value unless metric_value.nil? - - JSON.parse(out.to_json, symbolize_names: true) - end + # + # @param context [LDContext] + # @param key [String] + # @param data [any] + # @param metric_value [any] + # @return [Hash] + # + def custom_event(context, key, data, metric_value) + out = { + kind: "custom", + creationDate: starting_timestamp, + contextKeys: context.keys, + key: key, + } + out[:data] = data unless data.nil? + out[:metricValue] = metric_value unless metric_value.nil? + + JSON.parse(out.to_json, symbolize_names: true) + end - def flush_and_get_events(ep, sender) - ep.flush - ep.wait_until_inactive - sender.analytics_payloads.pop - end + def flush_and_get_events(ep, sender) + ep.flush + ep.wait_until_inactive + sender.analytics_payloads.pop + end - class FakeEventSender - attr_accessor :result - attr_reader :analytics_payloads - attr_reader :diagnostic_payloads + class FakeEventSender + attr_accessor :result + attr_reader :analytics_payloads + attr_reader :diagnostic_payloads - def initialize - @result = LaunchDarkly::Impl::EventSenderResult.new(true, false, nil) - @analytics_payloads = Queue.new - @diagnostic_payloads = Queue.new - end + def initialize + @result = Impl::EventSenderResult.new(true, false, nil) + @analytics_payloads = Queue.new + @diagnostic_payloads = Queue.new + end - def send_event_data(data, description, is_diagnostic) - (is_diagnostic ? @diagnostic_payloads : @analytics_payloads).push(JSON.parse(data, symbolize_names: true)) - @result + def send_event_data(data, description, is_diagnostic) + (is_diagnostic ? @diagnostic_payloads : @analytics_payloads).push(JSON.parse(data, symbolize_names: true)) + @result + end end end end diff --git a/spec/expiring_cache_spec.rb b/spec/expiring_cache_spec.rb index 7d757acf..0270fd25 100644 --- a/spec/expiring_cache_spec.rb +++ b/spec/expiring_cache_spec.rb @@ -1,76 +1,78 @@ require 'timecop' -describe LaunchDarkly::ExpiringCache do - subject { LaunchDarkly::ExpiringCache } - - before(:each) do - Timecop.freeze(Time.now) - end - - after(:each) do - Timecop.return - end - - it "evicts entries based on TTL" do - c = subject.new(3, 300) - c[:a] = 1 - c[:b] = 2 - - Timecop.freeze(Time.now + 330) - - c[:c] = 3 - - expect(c[:a]).to be nil - expect(c[:b]).to be nil - expect(c[:c]).to eq 3 - end - - it "evicts entries based on max size" do - c = subject.new(2, 300) - c[:a] = 1 - c[:b] = 2 - c[:c] = 3 - - expect(c[:a]).to be nil - expect(c[:b]).to eq 2 - expect(c[:c]).to eq 3 - end - - it "does not reset LRU on get" do - c = subject.new(2, 300) - c[:a] = 1 - c[:b] = 2 - c[:a] - c[:c] = 3 - - expect(c[:a]).to be nil - expect(c[:b]).to eq 2 - expect(c[:c]).to eq 3 - end - - it "resets LRU on put" do - c = subject.new(2, 300) - c[:a] = 1 - c[:b] = 2 - c[:a] = 1 - c[:c] = 3 - - expect(c[:a]).to eq 1 - expect(c[:b]).to be nil - expect(c[:c]).to eq 3 - end - - it "resets TTL on put" do - c = subject.new(3, 300) - c[:a] = 1 - c[:b] = 2 - - Timecop.freeze(Time.now + 330) - c[:a] = 1 - c[:c] = 3 - - expect(c[:a]).to eq 1 - expect(c[:b]).to be nil - expect(c[:c]).to eq 3 +module LaunchDarkly + describe ExpiringCache do + subject { ExpiringCache } + + before(:each) do + Timecop.freeze(Time.now) + end + + after(:each) do + Timecop.return + end + + it "evicts entries based on TTL" do + c = subject.new(3, 300) + c[:a] = 1 + c[:b] = 2 + + Timecop.freeze(Time.now + 330) + + c[:c] = 3 + + expect(c[:a]).to be nil + expect(c[:b]).to be nil + expect(c[:c]).to eq 3 + end + + it "evicts entries based on max size" do + c = subject.new(2, 300) + c[:a] = 1 + c[:b] = 2 + c[:c] = 3 + + expect(c[:a]).to be nil + expect(c[:b]).to eq 2 + expect(c[:c]).to eq 3 + end + + it "does not reset LRU on get" do + c = subject.new(2, 300) + c[:a] = 1 + c[:b] = 2 + c[:a] + c[:c] = 3 + + expect(c[:a]).to be nil + expect(c[:b]).to eq 2 + expect(c[:c]).to eq 3 + end + + it "resets LRU on put" do + c = subject.new(2, 300) + c[:a] = 1 + c[:b] = 2 + c[:a] = 1 + c[:c] = 3 + + expect(c[:a]).to eq 1 + expect(c[:b]).to be nil + expect(c[:c]).to eq 3 + end + + it "resets TTL on put" do + c = subject.new(3, 300) + c[:a] = 1 + c[:b] = 2 + + Timecop.freeze(Time.now + 330) + c[:a] = 1 + c[:c] = 3 + + expect(c[:a]).to eq 1 + expect(c[:b]).to be nil + expect(c[:c]).to eq 3 + end end end diff --git a/spec/feature_store_spec_base.rb b/spec/feature_store_spec_base.rb index 11df5969..c5f72d40 100644 --- a/spec/feature_store_spec_base.rb +++ b/spec/feature_store_spec_base.rb @@ -48,7 +48,7 @@ shared_examples "any_feature_store" do |store_tester| let(:store_tester) { store_tester } - def with_store() + def with_store ensure_stop(store_tester.create_feature_store) do |store| yield store end @@ -64,8 +64,8 @@ def with_inited_store(things) end end - def new_version_plus(f, deltaVersion, attrs = {}) - f.clone.merge({ version: f[:version] + deltaVersion }).merge(attrs) + def new_version_plus(f, delta_version, attrs = {}) + f.clone.merge({ version: f[:version] + delta_version }).merge(attrs) end it "is not initialized by default" do @@ -114,7 +114,7 @@ def new_version_plus(f, deltaVersion, attrs = {}) deleted: false, } with_inited_store([ $thing1, thing2 ]) do |store| - expect(store.all($things_kind)).to eq ({ $key1.to_sym => $thing1, key2.to_sym => thing2 }) + expect(store.all($things_kind)).to eq({ $key1.to_sym => $thing1, key2.to_sym => thing2 }) end end @@ -127,7 +127,7 @@ def new_version_plus(f, deltaVersion, attrs = {}) deleted: true, } with_inited_store([ $thing1, thing2 ]) do |store| - expect(store.all($things_kind)).to eq ({ $key1.to_sym => $thing1 }) + expect(store.all($things_kind)).to eq({ $key1.to_sym => $thing1 }) end end @@ -221,8 +221,6 @@ def new_version_plus(f, deltaVersion, attrs = {}) prefix_test_groups.each do |subgroup_description, prefix_options| # The following tests are done for each permutation of (caching/no caching) and (default prefix/specified prefix) context(subgroup_description) do - options = caching_options.merge(prefix_options).merge(base_options) - store_tester = store_tester_class.new(base_options) before(:each) { store_tester.clear_data } diff --git a/spec/flags_state_spec.rb b/spec/flags_state_spec.rb index 006fb88f..163fe872 100644 --- a/spec/flags_state_spec.rb +++ b/spec/flags_state_spec.rb @@ -1,84 +1,86 @@ require "spec_helper" require "json" -describe LaunchDarkly::FeatureFlagsState do - subject { LaunchDarkly::FeatureFlagsState } +module LaunchDarkly + describe FeatureFlagsState do + subject { FeatureFlagsState } - it "can get flag value" do - state = subject.new(true) - flag_state = { key: 'key', value: 'value', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - state.add_flag(flag_state, false, false) + it "can get flag value" do + state = subject.new(true) + flag_state = { key: 'key', value: 'value', variation: 1, reason: EvaluationReason.fallthrough(false) } + state.add_flag(flag_state, false, false) - expect(state.flag_value('key')).to eq 'value' - end + expect(state.flag_value('key')).to eq 'value' + end - it "returns nil for unknown flag" do - state = subject.new(true) + it "returns nil for unknown flag" do + state = subject.new(true) - expect(state.flag_value('key')).to be nil - end + expect(state.flag_value('key')).to be nil + end - it "can be converted to values map" do - state = subject.new(true) - flag_state1 = { key: 'key1', value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - flag_state2 = { key: 'key2', value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - state.add_flag(flag_state1, false, false) - state.add_flag(flag_state2, false, false) + it "can be converted to values map" do + state = subject.new(true) + flag_state1 = { key: 'key1', value: 'value1', variation: 0, reason: EvaluationReason.fallthrough(false) } + flag_state2 = { key: 'key2', value: 'value2', variation: 1, reason: EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) - expect(state.values_map).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) - end + expect(state.values_map).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) + end - it "can be converted to JSON structure" do - state = subject.new(true) - flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - # rubocop:disable Layout/LineLength - flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - state.add_flag(flag_state1, false, false) - state.add_flag(flag_state2, false, false) + it "can be converted to JSON structure" do + state = subject.new(true) + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: EvaluationReason.fallthrough(false) } + # rubocop:disable Layout/LineLength + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) - result = state.as_json - expect(result).to eq({ - 'key1' => 'value1', - 'key2' => 'value2', - '$flagsState' => { - 'key1' => { - :variation => 0, - :version => 100, + result = state.as_json + expect(result).to eq({ + 'key1' => 'value1', + 'key2' => 'value2', + '$flagsState' => { + 'key1' => { + :variation => 0, + :version => 100, + }, + 'key2' => { + :variation => 1, + :version => 200, + :trackEvents => true, + :debugEventsUntilDate => 1000, + }, }, - 'key2' => { - :variation => 1, - :version => 200, - :trackEvents => true, - :debugEventsUntilDate => 1000, - }, - }, - '$valid' => true, - }) - end + '$valid' => true, + }) + end - it "can be converted to JSON string" do - state = subject.new(true) - flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - # rubocop:disable Layout/LineLength - flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - state.add_flag(flag_state1, false, false) - state.add_flag(flag_state2, false, false) + it "can be converted to JSON string" do + state = subject.new(true) + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: EvaluationReason.fallthrough(false) } + # rubocop:disable Layout/LineLength + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) - object = state.as_json - str = state.to_json - expect(object.to_json).to eq(str) - end + object = state.as_json + str = state.to_json + expect(object.to_json).to eq(str) + end - it "uses our custom serializer with JSON.generate" do - state = subject.new(true) - flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - # rubocop:disable Layout/LineLength - flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } - state.add_flag(flag_state1, false, false) - state.add_flag(flag_state2, false, false) + it "uses our custom serializer with JSON.generate" do + state = subject.new(true) + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: EvaluationReason.fallthrough(false) } + # rubocop:disable Layout/LineLength + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) - stringFromToJson = state.to_json - stringFromGenerate = JSON.generate(state) - expect(stringFromGenerate).to eq(stringFromToJson) + string_from_to_json = state.to_json + string_from_generate = JSON.generate(state) + expect(string_from_generate).to eq(string_from_to_json) + end end end diff --git a/spec/http_util.rb b/spec/http_util.rb index 32cfd0fe..447c775a 100644 --- a/spec/http_util.rb +++ b/spec/http_util.rb @@ -84,27 +84,6 @@ def await_request_with_body end end -class StubProxyServer < StubHTTPServer - attr_reader :request_count - attr_accessor :connect_status - - def initialize - super - @request_count = 0 - end - - def create_server(port, base_opts) - WEBrick::HTTPProxyServer.new(base_opts.merge({ - ProxyContentHandler: proc do |req,res| - unless @connect_status.nil? - res.status = @connect_status - end - @request_count += 1 - end, - })) - end -end - class NullLogger def method_missing(*) self diff --git a/spec/impl/big_segments_spec.rb b/spec/impl/big_segments_spec.rb index 920cf941..e16e7a11 100644 --- a/spec/impl/big_segments_spec.rb +++ b/spec/impl/big_segments_spec.rb @@ -167,7 +167,7 @@ def with_manager(config) context "status polling" do it "detects store unavailability" do store = double - should_fail = Concurrent::AtomicBoolean.new(false) + should_fail = Concurrent::AtomicBoolean.new expect(store).to receive(:get_metadata).at_least(:once) do throw "sorry" if should_fail.value always_up_to_date @@ -178,24 +178,24 @@ def with_manager(config) with_manager(BigSegmentsConfig.new(store: store, status_poll_interval: 0.01)) do |m| m.status_provider.add_observer(SimpleObserver.new(->(value) { statuses << value })) - status1 = statuses.pop() + status1 = statuses.pop expect(status1.available).to be(true) should_fail.make_true - status2 = statuses.pop() + status2 = statuses.pop expect(status2.available).to be(false) should_fail.make_false - status3 = statuses.pop() + status3 = statuses.pop expect(status3.available).to be(true) end end it "detects stale status" do store = double - should_be_stale = Concurrent::AtomicBoolean.new(false) + should_be_stale = Concurrent::AtomicBoolean.new expect(store).to receive(:get_metadata).at_least(:once) do should_be_stale.value ? always_stale : always_up_to_date end @@ -205,17 +205,17 @@ def with_manager(config) with_manager(BigSegmentsConfig.new(store: store, status_poll_interval: 0.01)) do |m| m.status_provider.add_observer(SimpleObserver.new(->(value) { statuses << value })) - status1 = statuses.pop() + status1 = statuses.pop expect(status1.stale).to be(false) should_be_stale.make_true - status2 = statuses.pop() + status2 = statuses.pop expect(status2.stale).to be(true) should_be_stale.make_false - status3 = statuses.pop() + status3 = statuses.pop expect(status3.stale).to be(false) end end diff --git a/spec/impl/context_spec.rb b/spec/impl/context_spec.rb index ce9d6ff5..dd798c25 100644 --- a/spec/impl/context_spec.rb +++ b/spec/impl/context_spec.rb @@ -1,31 +1,35 @@ require "ldclient-rb/impl/context" -describe LaunchDarkly::Impl::Context do - subject { LaunchDarkly::Impl::Context } +module LaunchDarkly + module Impl + describe Context do + subject { Context } - it "can validate kind correctly" do - test_cases = [ - [:user_context, LaunchDarkly::Impl::Context::ERR_KIND_NON_STRING], - ["kind", LaunchDarkly::Impl::Context::ERR_KIND_CANNOT_BE_KIND], - ["multi", LaunchDarkly::Impl::Context::ERR_KIND_CANNOT_BE_MULTI], - ["user@type", LaunchDarkly::Impl::Context::ERR_KIND_INVALID_CHARS], - ["org", nil], - ] + it "can validate kind correctly" do + test_cases = [ + [:user_context, Context::ERR_KIND_NON_STRING], + ["kind", Context::ERR_KIND_CANNOT_BE_KIND], + ["multi", Context::ERR_KIND_CANNOT_BE_MULTI], + ["user@type", Context::ERR_KIND_INVALID_CHARS], + ["org", nil], + ] - test_cases.each do |input, expected| - expect(subject.validate_kind(input)).to eq(expected) - end - end + test_cases.each do |input, expected| + expect(subject.validate_kind(input)).to eq(expected) + end + end - it "can validate a key correctly" do - test_cases = [ - [:key, LaunchDarkly::Impl::Context::ERR_KEY_NON_STRING], - ["", LaunchDarkly::Impl::Context::ERR_KEY_EMPTY], - ["key", nil], - ] + it "can validate a key correctly" do + test_cases = [ + [:key, Context::ERR_KEY_NON_STRING], + ["", Context::ERR_KEY_EMPTY], + ["key", nil], + ] - test_cases.each do |input, expected| - expect(subject.validate_key(input)).to eq(expected) + test_cases.each do |input, expected| + expect(subject.validate_key(input)).to eq(expected) + end + end end end -end \ No newline at end of file +end diff --git a/spec/impl/evaluator_bucketing_spec.rb b/spec/impl/evaluator_bucketing_spec.rb index 005dd888..2c761d67 100644 --- a/spec/impl/evaluator_bucketing_spec.rb +++ b/spec/impl/evaluator_bucketing_spec.rb @@ -1,221 +1,227 @@ require "model_builders" require "spec_helper" -describe LaunchDarkly::Impl::EvaluatorBucketing do - subject { LaunchDarkly::Impl::EvaluatorBucketing } - - describe "bucket_context" do - describe "seed exists" do - let(:seed) { 61 } - it "returns the expected bucket values for seed" do - context = LaunchDarkly::LDContext.create({ key: "userKeyA" }) - bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) - expect(bucket).to be_within(0.0000001).of(0.09801207) - - context = LaunchDarkly::LDContext.create({ key: "userKeyB" }) - bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) - expect(bucket).to be_within(0.0000001).of(0.14483777) - - context = LaunchDarkly::LDContext.create({ key: "userKeyC" }) - bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) - expect(bucket).to be_within(0.0000001).of(0.9242641) +module LaunchDarkly + module Impl + describe EvaluatorBucketing do + subject { EvaluatorBucketing } + + describe "bucket_context" do + describe "seed exists" do + let(:seed) { 61 } + it "returns the expected bucket values for seed" do + context = LaunchDarkly::LDContext.create({ key: "userKeyA" }) + bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) + expect(bucket).to be_within(0.0000001).of(0.09801207) + + context = LaunchDarkly::LDContext.create({ key: "userKeyB" }) + bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) + expect(bucket).to be_within(0.0000001).of(0.14483777) + + context = LaunchDarkly::LDContext.create({ key: "userKeyC" }) + bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) + expect(bucket).to be_within(0.0000001).of(0.9242641) + end + + it "returns the same bucket regardless of hashKey and salt" do + context = LaunchDarkly::LDContext.create({ key: "userKeyA" }) + bucket1 = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) + bucket2 = subject.bucket_context(context, context.kind, "hashKey1", "key", "saltyB", seed) + bucket3 = subject.bucket_context(context, context.kind, "hashKey2", "key", "saltyC", seed) + expect(bucket1).to eq(bucket2) + expect(bucket2).to eq(bucket3) + end + + it "returns a different bucket if the seed is not the same" do + context = LaunchDarkly::LDContext.create({ key: "userKeyA" }) + bucket1 = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) + bucket2 = subject.bucket_context(context, context.kind, "hashKey1", "key", "saltyB", seed + 1) + expect(bucket1).to_not eq(bucket2) + end + + it "returns a different bucket if the context is not the same" do + context1 = LaunchDarkly::LDContext.create({ key: "userKeyA" }) + context2 = LaunchDarkly::LDContext.create({ key: "userKeyB" }) + bucket1 = subject.bucket_context(context1, context1.kind, "hashKey", "key", "saltyA", seed) + bucket2 = subject.bucket_context(context2, context2.kind, "hashKey1", "key", "saltyB", seed) + expect(bucket1).to_not eq(bucket2) + end + end + + it "gets expected bucket values for specific keys" do + context = LaunchDarkly::LDContext.create({ key: "userKeyA" }) + bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", nil) + expect(bucket).to be_within(0.0000001).of(0.42157587) + + context = LaunchDarkly::LDContext.create({ key: "userKeyB" }) + bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", nil) + expect(bucket).to be_within(0.0000001).of(0.6708485) + + context = LaunchDarkly::LDContext.create({ key: "userKeyC" }) + bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", nil) + expect(bucket).to be_within(0.0000001).of(0.10343106) + end + + it "treats the bucket by attribute as a reference when a context kind isn't specified" do + context = LaunchDarkly::LDContext.create({ key: "userKeyB", kind: "user", address: { street: "123 Easy St", city: "Anytown" } }) + bucket = subject.bucket_context(context, context.kind, "hashKey", "/address/street", "saltyA", nil) + expect(bucket).to be_within(0.0000001).of(0.56809287) + + bucket = subject.bucket_context(context, nil, "hashKey", "/address/street", "saltyA", nil) + expect(bucket).to be_within(0.0000001).of(0) + end + + it "can bucket by int value (equivalent to string)" do + context = LaunchDarkly::LDContext.create({ + key: "userkey", + custom: { + stringAttr: "33333", + intAttr: 33333, + }, + }) + string_result = subject.bucket_context(context, context.kind, "hashKey", "stringAttr", "saltyA", nil) + int_result = subject.bucket_context(context, context.kind, "hashKey", "intAttr", "saltyA", nil) + + expect(int_result).to be_within(0.0000001).of(0.54771423) + expect(int_result).to eq(string_result) + end + + it "cannot bucket by float value" do + context = LaunchDarkly::LDContext.create({ + key: "userkey", + custom: { + floatAttr: 33.5, + }, + }) + result = subject.bucket_context(context, context.kind, "hashKey", "floatAttr", "saltyA", nil) + expect(result).to eq(0.0) + end + + it "cannot bucket by bool value" do + context = LaunchDarkly::LDContext.create({ + key: "userkey", + custom: { + boolAttr: true, + }, + }) + result = subject.bucket_context(context, context.kind, "hashKey", "boolAttr", "saltyA", nil) + expect(result).to eq(0.0) + end end - it "returns the same bucket regardless of hashKey and salt" do - context = LaunchDarkly::LDContext.create({ key: "userKeyA" }) - bucket1 = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) - bucket2 = subject.bucket_context(context, context.kind, "hashKey1", "key", "saltyB", seed) - bucket3 = subject.bucket_context(context, context.kind, "hashKey2", "key", "saltyC", seed) - expect(bucket1).to eq(bucket2) - expect(bucket2).to eq(bucket3) + describe "variation_index_for_context" do + context "rollout is not an experiment" do + it "matches bucket" do + context = LaunchDarkly::LDContext.create({ key: "userkey" }) + flag_key = "flagkey" + salt = "salt" + + # First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, + # so we can construct a rollout whose second bucket just barely contains that value + bucket_value = (subject.bucket_context(context, context.kind, flag_key, "key", salt, nil) * 100000).truncate + expect(bucket_value).to be > 0 + expect(bucket_value).to be < 100000 + + bad_variation_a = 0 + matched_variation = 1 + bad_variation_b = 2 + vr = Model::VariationOrRollout.new( + nil, + { + variations: [ + { variation: bad_variation_a, weight: bucket_value }, # end of bucket range is not inclusive, so it will *not* match the target value + { variation: matched_variation, weight: 1 }, # size of this bucket is 1, so it only matches that specific value + { variation: bad_variation_b, weight: 100000 - (bucket_value + 1) }, + ], + } + ) + flag = Flags.from_hash({ key: flag_key, salt: salt }) + + result_variation, in_experiment = subject.variation_index_for_context(flag, vr, context) + expect(result_variation).to be matched_variation + expect(in_experiment).to be(false) + end + + it "uses last bucket if bucket value is equal to total weight" do + context = LaunchDarkly::LDContext.create({ key: "userkey" }) + flag_key = "flagkey" + salt = "salt" + + bucket_value = (subject.bucket_context(context, context.kind, flag_key, "key", salt, nil) * 100000).truncate + + # We'll construct a list of variations that stops right at the target bucket value + vr = Model::VariationOrRollout.new(nil, + { + variations: [ + { variation: 0, weight: bucket_value }, + ], + }) + flag = Flags.from_hash({ key: flag_key, salt: salt }) + + result_variation, in_experiment = subject.variation_index_for_context(flag, vr, context) + expect(result_variation).to be 0 + expect(in_experiment).to be(false) + end + end end - it "returns a different bucket if the seed is not the same" do - context = LaunchDarkly::LDContext.create({ key: "userKeyA" }) - bucket1 = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", seed) - bucket2 = subject.bucket_context(context, context.kind, "hashKey1", "key", "saltyB", seed+1) - expect(bucket1).to_not eq(bucket2) + context "rollout is an experiment" do + it "returns whether context is in the experiment or not" do + context1 = LaunchDarkly::LDContext.create({ key: "userKeyA" }) + context2 = LaunchDarkly::LDContext.create({ key: "userKeyB" }) + context3 = LaunchDarkly::LDContext.create({ key: "userKeyC" }) + flag_key = "flagkey" + salt = "salt" + seed = 61 + + vr = Model::VariationOrRollout.new(nil, + { + seed: seed, + kind: 'experiment', + variations: [ + { variation: 0, weight: 10000, untracked: false }, + { variation: 2, weight: 20000, untracked: false }, + { variation: 0, weight: 70000, untracked: true }, + ], + }) + flag = Flags.from_hash({ key: flag_key, salt: salt }) + + result_variation, in_experiment = subject.variation_index_for_context(flag, vr, context1) + expect(result_variation).to be(0) + expect(in_experiment).to be(true) + result_variation, in_experiment = subject.variation_index_for_context(flag, vr, context2) + expect(result_variation).to be(2) + expect(in_experiment).to be(true) + result_variation, in_experiment = subject.variation_index_for_context(flag, vr, context3) + expect(result_variation).to be(0) + expect(in_experiment).to be(false) + end + + it "uses last bucket if bucket value is equal to total weight" do + context = LaunchDarkly::LDContext.create({ key: "userkey" }) + flag_key = "flagkey" + salt = "salt" + seed = 61 + + bucket_value = (subject.bucket_context(context, context.kind, flag_key, "key", salt, seed) * 100000).truncate + + # We'll construct a list of variations that stops right at the target bucket value + vr = Model::VariationOrRollout.new(nil, + { + seed: seed, + kind: 'experiment', + variations: [ + { variation: 0, weight: bucket_value, untracked: false }, + ], + }) + flag = Flags.from_hash({ key: flag_key, salt: salt }) + + result_variation, in_experiment = subject.variation_index_for_context(flag, vr, context) + expect(result_variation).to be 0 + expect(in_experiment).to be(true) + end end - - it "returns a different bucket if the context is not the same" do - context1 = LaunchDarkly::LDContext.create({ key: "userKeyA" }) - context2 = LaunchDarkly::LDContext.create({ key: "userKeyB" }) - bucket1 = subject.bucket_context(context1, context1.kind, "hashKey", "key", "saltyA", seed) - bucket2 = subject.bucket_context(context2, context2.kind, "hashKey1", "key", "saltyB", seed) - expect(bucket1).to_not eq(bucket2) - end - end - - it "gets expected bucket values for specific keys" do - context = LaunchDarkly::LDContext.create({ key: "userKeyA" }) - bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", nil) - expect(bucket).to be_within(0.0000001).of(0.42157587) - - context = LaunchDarkly::LDContext.create({ key: "userKeyB" }) - bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", nil) - expect(bucket).to be_within(0.0000001).of(0.6708485) - - context = LaunchDarkly::LDContext.create({ key: "userKeyC" }) - bucket = subject.bucket_context(context, context.kind, "hashKey", "key", "saltyA", nil) - expect(bucket).to be_within(0.0000001).of(0.10343106) - end - - it "treats the bucket by attribute as a reference when a context kind isn't specified" do - context = LaunchDarkly::LDContext.create({ key: "userKeyB", kind: "user", address: { street: "123 Easy St", city: "Anytown" } }) - bucket = subject.bucket_context(context, context.kind, "hashKey", "/address/street", "saltyA", nil) - expect(bucket).to be_within(0.0000001).of(0.56809287) - - bucket = subject.bucket_context(context, nil, "hashKey", "/address/street", "saltyA", nil) - expect(bucket).to be_within(0.0000001).of(0) - end - - it "can bucket by int value (equivalent to string)" do - context = LaunchDarkly::LDContext.create({ - key: "userkey", - custom: { - stringAttr: "33333", - intAttr: 33333, - }, - }) - stringResult = subject.bucket_context(context, context.kind, "hashKey", "stringAttr", "saltyA", nil) - intResult = subject.bucket_context(context, context.kind, "hashKey", "intAttr", "saltyA", nil) - - expect(intResult).to be_within(0.0000001).of(0.54771423) - expect(intResult).to eq(stringResult) - end - - it "cannot bucket by float value" do - context = LaunchDarkly::LDContext.create({ - key: "userkey", - custom: { - floatAttr: 33.5, - }, - }) - result = subject.bucket_context(context, context.kind, "hashKey", "floatAttr", "saltyA", nil) - expect(result).to eq(0.0) end - - it "cannot bucket by bool value" do - context = LaunchDarkly::LDContext.create({ - key: "userkey", - custom: { - boolAttr: true, - }, - }) - result = subject.bucket_context(context, context.kind, "hashKey", "boolAttr", "saltyA", nil) - expect(result).to eq(0.0) - end - end - - describe "variation_index_for_context" do - context "rollout is not an experiment" do - it "matches bucket" do - context = LaunchDarkly::LDContext.create({ key: "userkey" }) - flag_key = "flagkey" - salt = "salt" - - # First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, - # so we can construct a rollout whose second bucket just barely contains that value - bucket_value = (subject.bucket_context(context, context.kind, flag_key, "key", salt, nil) * 100000).truncate() - expect(bucket_value).to be > 0 - expect(bucket_value).to be < 100000 - - bad_variation_a = 0 - matched_variation = 1 - bad_variation_b = 2 - vr = LaunchDarkly::Impl::Model::VariationOrRollout.new(nil, - { - variations: [ - { variation: bad_variation_a, weight: bucket_value }, # end of bucket range is not inclusive, so it will *not* match the target value - { variation: matched_variation, weight: 1 }, # size of this bucket is 1, so it only matches that specific value - { variation: bad_variation_b, weight: 100000 - (bucket_value + 1) }, - ], - }) - flag = Flags.from_hash({ key: flag_key, salt: salt }) - - result_variation, inExperiment = subject.variation_index_for_context(flag, vr, context) - expect(result_variation).to be matched_variation - expect(inExperiment).to be(false) - end - - it "uses last bucket if bucket value is equal to total weight" do - context = LaunchDarkly::LDContext.create({ key: "userkey" }) - flag_key = "flagkey" - salt = "salt" - - bucket_value = (subject.bucket_context(context, context.kind, flag_key, "key", salt, nil) * 100000).truncate() - - # We'll construct a list of variations that stops right at the target bucket value - vr = LaunchDarkly::Impl::Model::VariationOrRollout.new(nil, - { - variations: [ - { variation: 0, weight: bucket_value }, - ], - }) - flag = Flags.from_hash({ key: flag_key, salt: salt }) - - result_variation, inExperiment = subject.variation_index_for_context(flag, vr, context) - expect(result_variation).to be 0 - expect(inExperiment).to be(false) - end - end - end - - context "rollout is an experiment" do - it "returns whether context is in the experiment or not" do - context1 = LaunchDarkly::LDContext.create({ key: "userKeyA" }) - context2 = LaunchDarkly::LDContext.create({ key: "userKeyB" }) - context3 = LaunchDarkly::LDContext.create({ key: "userKeyC" }) - flag_key = "flagkey" - salt = "salt" - seed = 61 - - vr = LaunchDarkly::Impl::Model::VariationOrRollout.new(nil, - { - seed: seed, - kind: 'experiment', - variations: [ - { variation: 0, weight: 10000, untracked: false }, - { variation: 2, weight: 20000, untracked: false }, - { variation: 0, weight: 70000 , untracked: true }, - ], - }) - flag = Flags.from_hash({ key: flag_key, salt: salt }) - - result_variation, inExperiment = subject.variation_index_for_context(flag, vr, context1) - expect(result_variation).to be(0) - expect(inExperiment).to be(true) - result_variation, inExperiment = subject.variation_index_for_context(flag, vr, context2) - expect(result_variation).to be(2) - expect(inExperiment).to be(true) - result_variation, inExperiment = subject.variation_index_for_context(flag, vr, context3) - expect(result_variation).to be(0) - expect(inExperiment).to be(false) - end - - it "uses last bucket if bucket value is equal to total weight" do - context = LaunchDarkly::LDContext.create({ key: "userkey" }) - flag_key = "flagkey" - salt = "salt" - seed = 61 - - bucket_value = (subject.bucket_context(context, context.kind, flag_key, "key", salt, seed) * 100000).truncate() - - # We'll construct a list of variations that stops right at the target bucket value - vr = LaunchDarkly::Impl::Model::VariationOrRollout.new(nil, - { - seed: seed, - kind: 'experiment', - variations: [ - { variation: 0, weight: bucket_value, untracked: false }, - ], - }) - flag = Flags.from_hash({ key: flag_key, salt: salt }) - - result_variation, inExperiment = subject.variation_index_for_context(flag, vr, context) - expect(result_variation).to be 0 - expect(inExperiment).to be(true) - end end end diff --git a/spec/impl/evaluator_operators_spec.rb b/spec/impl/evaluator_operators_spec.rb index a38cb3f4..55317d28 100644 --- a/spec/impl/evaluator_operators_spec.rb +++ b/spec/impl/evaluator_operators_spec.rb @@ -1,105 +1,109 @@ require "spec_helper" -describe LaunchDarkly::Impl::EvaluatorOperators do - subject { LaunchDarkly::Impl::EvaluatorOperators } +module LaunchDarkly + module Impl + describe EvaluatorOperators do + subject { EvaluatorOperators } - describe "operators" do - dateStr1 = "2017-12-06T00:00:00.000-07:00" - dateStr2 = "2017-12-06T00:01:01.000-07:00" - dateMs1 = 10000000 - dateMs2 = 10000001 - invalidDate = "hey what's this?" + describe "operators" do + date_str_1 = "2017-12-06T00:00:00.000-07:00" + date_str_2 = "2017-12-06T00:01:01.000-07:00" + date_ms_1 = 10000000 + date_ms_2 = 10000001 + invalid_date = "hey what's this?" - operatorTests = [ - # numeric comparisons - [ :in, 99, 99, true ], - [ :in, 99.0001, 99.0001, true ], - [ :in, 99, 99.0001, false ], - [ :in, 99.0001, 99, false ], - [ :lessThan, 99, 99.0001, true ], - [ :lessThan, 99.0001, 99, false ], - [ :lessThan, 99, 99, false ], - [ :lessThanOrEqual, 99, 99.0001, true ], - [ :lessThanOrEqual, 99.0001, 99, false ], - [ :lessThanOrEqual, 99, 99, true ], - [ :greaterThan, 99.0001, 99, true ], - [ :greaterThan, 99, 99.0001, false ], - [ :greaterThan, 99, 99, false ], - [ :greaterThanOrEqual, 99.0001, 99, true ], - [ :greaterThanOrEqual, 99, 99.0001, false ], - [ :greaterThanOrEqual, 99, 99, true ], + operator_tests = [ + # numeric comparisons + [ :in, 99, 99, true ], + [ :in, 99.0001, 99.0001, true ], + [ :in, 99, 99.0001, false ], + [ :in, 99.0001, 99, false ], + [ :lessThan, 99, 99.0001, true ], + [ :lessThan, 99.0001, 99, false ], + [ :lessThan, 99, 99, false ], + [ :lessThanOrEqual, 99, 99.0001, true ], + [ :lessThanOrEqual, 99.0001, 99, false ], + [ :lessThanOrEqual, 99, 99, true ], + [ :greaterThan, 99.0001, 99, true ], + [ :greaterThan, 99, 99.0001, false ], + [ :greaterThan, 99, 99, false ], + [ :greaterThanOrEqual, 99.0001, 99, true ], + [ :greaterThanOrEqual, 99, 99.0001, false ], + [ :greaterThanOrEqual, 99, 99, true ], - # string comparisons - [ :in, "x", "x", true ], - [ :in, "x", "xyz", false ], - [ :startsWith, "xyz", "x", true ], - [ :startsWith, "x", "xyz", false ], - [ :endsWith, "xyz", "z", true ], - [ :endsWith, "z", "xyz", false ], - [ :contains, "xyz", "y", true ], - [ :contains, "y", "xyz", false ], + # string comparisons + [ :in, "x", "x", true ], + [ :in, "x", "xyz", false ], + [ :startsWith, "xyz", "x", true ], + [ :startsWith, "x", "xyz", false ], + [ :endsWith, "xyz", "z", true ], + [ :endsWith, "z", "xyz", false ], + [ :contains, "xyz", "y", true ], + [ :contains, "y", "xyz", false ], - # mixed strings and numbers - [ :in, "99", 99, false ], - [ :in, 99, "99", false ], - [ :contains, "99", 99, false ], - [ :startsWith, "99", 99, false ], - [ :endsWith, "99", 99, false ], - [ :lessThanOrEqual, "99", 99, false ], - [ :lessThanOrEqual, 99, "99", false ], - [ :greaterThanOrEqual, "99", 99, false ], - [ :greaterThanOrEqual, 99, "99", false ], + # mixed strings and numbers + [ :in, "99", 99, false ], + [ :in, 99, "99", false ], + [ :contains, "99", 99, false ], + [ :startsWith, "99", 99, false ], + [ :endsWith, "99", 99, false ], + [ :lessThanOrEqual, "99", 99, false ], + [ :lessThanOrEqual, 99, "99", false ], + [ :greaterThanOrEqual, "99", 99, false ], + [ :greaterThanOrEqual, 99, "99", false ], - # regex - [ :matches, "hello world", "hello.*rld", true ], - [ :matches, "hello world", "hello.*orl", true ], - [ :matches, "hello world", "l+", true ], - [ :matches, "hello world", "(world|planet)", true ], - [ :matches, "hello world", "aloha", false ], - [ :matches, "hello world", "***not a regex", false ], + # regex + [ :matches, "hello world", "hello.*rld", true ], + [ :matches, "hello world", "hello.*orl", true ], + [ :matches, "hello world", "l+", true ], + [ :matches, "hello world", "(world|planet)", true ], + [ :matches, "hello world", "aloha", false ], + [ :matches, "hello world", "***not a regex", false ], - # dates - [ :before, dateStr1, dateStr2, true ], - [ :before, dateMs1, dateMs2, true ], - [ :before, dateStr2, dateStr1, false ], - [ :before, dateMs2, dateMs1, false ], - [ :before, dateStr1, dateStr1, false ], - [ :before, dateMs1, dateMs1, false ], - [ :before, dateStr1, invalidDate, false ], - [ :after, dateStr1, dateStr2, false ], - [ :after, dateMs1, dateMs2, false ], - [ :after, dateStr2, dateStr1, true ], - [ :after, dateMs2, dateMs1, true ], - [ :after, dateStr1, dateStr1, false ], - [ :after, dateMs1, dateMs1, false ], - [ :after, dateStr1, invalidDate, false ], + # dates + [:before, date_str_1, date_str_2, true ], + [:before, date_ms_1, date_ms_2, true ], + [:before, date_str_2, date_str_1, false ], + [:before, date_ms_2, date_ms_1, false ], + [:before, date_str_1, date_str_1, false ], + [:before, date_ms_1, date_ms_1, false ], + [:before, date_str_1, invalid_date, false ], + [:after, date_str_1, date_str_2, false ], + [:after, date_ms_1, date_ms_2, false ], + [:after, date_str_2, date_str_1, true ], + [:after, date_ms_2, date_ms_1, true ], + [:after, date_str_1, date_str_1, false ], + [:after, date_ms_1, date_ms_1, false ], + [:after, date_str_1, invalid_date, false ], - # semver - [ :semVerEqual, "2.0.1", "2.0.1", true ], - [ :semVerEqual, "2.0", "2.0.0", true ], - [ :semVerEqual, "2-rc1", "2.0.0-rc1", true ], - [ :semVerEqual, "2+build2", "2.0.0+build2", true ], - [ :semVerLessThan, "2.0.0", "2.0.1", true ], - [ :semVerLessThan, "2.0", "2.0.1", true ], - [ :semVerLessThan, "2.0.1", "2.0.0", false ], - [ :semVerLessThan, "2.0.1", "2.0", false ], - [ :semVerLessThan, "2.0.0-rc", "2.0.0-rc.beta", true ], - [ :semVerGreaterThan, "2.0.1", "2.0.0", true ], - [ :semVerGreaterThan, "2.0.1", "2.0", true ], - [ :semVerGreaterThan, "2.0.0", "2.0.1", false ], - [ :semVerGreaterThan, "2.0", "2.0.1", false ], - [ :semVerGreaterThan, "2.0.0-rc.1", "2.0.0-rc.0", true ], - [ :semVerLessThan, "2.0.1", "xbad%ver", false ], - [ :semVerGreaterThan, "2.0.1", "xbad%ver", false ], - ] + # semver + [ :semVerEqual, "2.0.1", "2.0.1", true ], + [ :semVerEqual, "2.0", "2.0.0", true ], + [ :semVerEqual, "2-rc1", "2.0.0-rc1", true ], + [ :semVerEqual, "2+build2", "2.0.0+build2", true ], + [ :semVerLessThan, "2.0.0", "2.0.1", true ], + [ :semVerLessThan, "2.0", "2.0.1", true ], + [ :semVerLessThan, "2.0.1", "2.0.0", false ], + [ :semVerLessThan, "2.0.1", "2.0", false ], + [ :semVerLessThan, "2.0.0-rc", "2.0.0-rc.beta", true ], + [ :semVerGreaterThan, "2.0.1", "2.0.0", true ], + [ :semVerGreaterThan, "2.0.1", "2.0", true ], + [ :semVerGreaterThan, "2.0.0", "2.0.1", false ], + [ :semVerGreaterThan, "2.0", "2.0.1", false ], + [ :semVerGreaterThan, "2.0.0-rc.1", "2.0.0-rc.0", true ], + [ :semVerLessThan, "2.0.1", "xbad%ver", false ], + [ :semVerGreaterThan, "2.0.1", "xbad%ver", false ], + ] - operatorTests.each do |params| - op = params[0] - value1 = params[1] - value2 = params[2] - shouldBe = params[3] - it "should return #{shouldBe} for #{value1} #{op} #{value2}" do - expect(subject::apply(op, value1, value2)).to be shouldBe + operator_tests.each do |params| + op = params[0] + value1 = params[1] + value2 = params[2] + should_be = params[3] + it "should return #{should_be} for #{value1} #{op} #{value2}" do + expect(subject::apply(op, value1, value2)).to be should_be + end + end end end end diff --git a/spec/impl/evaluator_rule_spec.rb b/spec/impl/evaluator_rule_spec.rb index 11c07ecb..a41558e6 100644 --- a/spec/impl/evaluator_rule_spec.rb +++ b/spec/impl/evaluator_rule_spec.rb @@ -19,7 +19,6 @@ module Impl rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 1 } flag = Flags.boolean_flag_with_rules(rule) context = LDContext.create({ key: 'userkey' }) - detail = EvaluationDetail.new(true, 1, EvaluationReason::rule_match(0, 'ruleid')) result1 = basic_evaluator.evaluate(flag, context) result2 = basic_evaluator.evaluate(flag, context) expect(result1.detail.reason.rule_id).to eq 'ruleid' diff --git a/spec/impl/evaluator_segment_spec.rb b/spec/impl/evaluator_segment_spec.rb index bc23c3d7..8fdb0a05 100644 --- a/spec/impl/evaluator_segment_spec.rb +++ b/spec/impl/evaluator_segment_spec.rb @@ -80,62 +80,62 @@ def test_segment_match(segment, context) end it 'matches context by rule when weight is absent' do - segClause = Clauses.match_context(user_context, :email) - segRule = { - clauses: [ segClause ], + seg_clause = Clauses.match_context(user_context, :email) + seg_rule = { + clauses: [seg_clause ], } - segment = SegmentBuilder.new('segkey').rule(segRule).build + segment = SegmentBuilder.new('segkey').rule(seg_rule).build expect(test_segment_match(segment, user_context)).to be true end it 'matches context by rule when weight is nil' do - segClause = Clauses.match_context(user_context, :email) - segRule = { - clauses: [ segClause ], + seg_clause = Clauses.match_context(user_context, :email) + seg_rule = { + clauses: [seg_clause ], weight: nil, } - segment = SegmentBuilder.new('segkey').rule(segRule).build + segment = SegmentBuilder.new('segkey').rule(seg_rule).build expect(test_segment_match(segment, user_context)).to be true end it 'matches context with full rollout' do - segClause = Clauses.match_context(user_context, :email) - segRule = { - clauses: [ segClause ], + seg_clause = Clauses.match_context(user_context, :email) + seg_rule = { + clauses: [seg_clause ], weight: 100000, } - segment = SegmentBuilder.new('segkey').rule(segRule).build + segment = SegmentBuilder.new('segkey').rule(seg_rule).build expect(test_segment_match(segment, user_context)).to be true end it "doesn't match context with zero rollout" do - segClause = Clauses.match_context(user_context, :email) - segRule = { - clauses: [ segClause ], + seg_clause = Clauses.match_context(user_context, :email) + seg_rule = { + clauses: [seg_clause ], weight: 0, } - segment = SegmentBuilder.new('segkey').rule(segRule).build + segment = SegmentBuilder.new('segkey').rule(seg_rule).build expect(test_segment_match(segment, user_context)).to be false end it "matches context with multiple clauses" do - segClause1 = Clauses.match_context(user_context, :email) - segClause2 = Clauses.match_context(user_context, :name) - segRule = { - clauses: [ segClause1, segClause2 ], + seg_clause_1 = Clauses.match_context(user_context, :email) + seg_clause_2 = Clauses.match_context(user_context, :name) + seg_rule = { + clauses: [seg_clause_1, seg_clause_2 ], } - segment = SegmentBuilder.new('segkey').rule(segRule).build + segment = SegmentBuilder.new('segkey').rule(seg_rule).build expect(test_segment_match(segment, user_context)).to be true end it "doesn't match context with multiple clauses if a clause doesn't match" do - segClause1 = Clauses.match_context(user_context, :email) - segClause2 = Clauses.match_context(user_context, :name) - segClause2[:values] = [ 'wrong' ] - segRule = { - clauses: [ segClause1, segClause2 ], + seg_clause_1 = Clauses.match_context(user_context, :email) + seg_clause_2 = Clauses.match_context(user_context, :name) + seg_clause_2[:values] = ['wrong' ] + seg_rule = { + clauses: [seg_clause_1, seg_clause_2 ], } - segment = SegmentBuilder.new('segkey').rule(segRule).build + segment = SegmentBuilder.new('segkey').rule(seg_rule).build expect(test_segment_match(segment, user_context)).to be false end diff --git a/spec/impl/evaluator_spec.rb b/spec/impl/evaluator_spec.rb index 47a933d3..0828e957 100644 --- a/spec/impl/evaluator_spec.rb +++ b/spec/impl/evaluator_spec.rb @@ -13,7 +13,7 @@ module Impl on: false, offVariation: 1, fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'x' }) detail = EvaluationDetail.new('b', 1, EvaluationReason::off) @@ -27,7 +27,7 @@ module Impl key: 'feature', on: false, fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'x' }) detail = EvaluationDetail.new(nil, nil, EvaluationReason::off) @@ -42,7 +42,7 @@ module Impl on: false, offVariation: 1, fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'x' }) detail = EvaluationDetail.new('b', 1, EvaluationReason::off) @@ -58,7 +58,7 @@ module Impl on: false, offVariation: 999, fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'x' }) detail = EvaluationDetail.new(nil, nil, @@ -74,7 +74,7 @@ module Impl on: false, offVariation: -1, fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'x' }) detail = EvaluationDetail.new(nil, nil, @@ -90,7 +90,7 @@ module Impl on: true, fallthrough: { variation: 0 }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], version: 1, rules: [ { variation: 2, clauses: [ { attribute: "key", op: "in", values: ["zzz"] } ] }, @@ -109,7 +109,7 @@ module Impl on: true, fallthrough: { variation: 0 }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], version: 1, rules: [ { variation: 2, clauses: [ { attribute: "key", op: "in", values: ["zzz"] } ] }, @@ -129,7 +129,7 @@ module Impl on: true, fallthrough: { variation: 999 }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) @@ -144,7 +144,7 @@ module Impl on: true, fallthrough: { variation: -1 }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) @@ -159,7 +159,7 @@ module Impl on: true, fallthrough: { }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) @@ -174,7 +174,7 @@ module Impl on: true, fallthrough: { rollout: { variations: [] } }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) @@ -188,11 +188,11 @@ module Impl key: 'feature', on: true, targets: [ - { values: [ 'whoever', 'userkey' ], variation: 2 }, + { values: %w[whoever userkey], variation: 2 }, ], fallthrough: { variation: 0 }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) detail = EvaluationDetail.new('c', 2, EvaluationReason::target_match) @@ -206,11 +206,11 @@ module Impl key: 'feature', on: true, targets: [ - { values: [ 'whoever', 'userkey' ], variation: 2 }, + { values: %w[whoever userkey], variation: 2 }, ], fallthrough: { variation: 0 }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) detail = EvaluationDetail.new('c', 2, EvaluationReason::target_match) @@ -227,7 +227,7 @@ module Impl on: true, fallthrough: { rollout: { variations: [ { weight: 100000, variation: 1, untracked: false } ] } }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], version: 1, }) context = LDContext.create({ key: 'x' }) @@ -243,7 +243,7 @@ module Impl on: true, fallthrough: { rollout: { variations: [ { weight: 100000, variation: 1, untracked: false } ] } }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], version: 1, }) context = LDContext.create({ key: 'x' }) @@ -260,7 +260,7 @@ module Impl on: true, fallthrough: { rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: false } ] } }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) result = basic_evaluator.evaluate(flag, context) @@ -274,7 +274,7 @@ module Impl on: true, fallthrough: { rollout: { kind: 'rollout', variations: [ { weight: 100000, variation: 1, untracked: false } ] } }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) result = basic_evaluator.evaluate(flag, context) @@ -288,7 +288,7 @@ module Impl on: true, fallthrough: { rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: true } ] } }, offVariation: 1, - variations: ['a', 'b', 'c'], + variations: %w[a b c], }) context = LDContext.create({ key: 'userkey' }) result = basic_evaluator.evaluate(flag, context) diff --git a/spec/event_sender_spec.rb b/spec/impl/event_sender_spec.rb similarity index 96% rename from spec/event_sender_spec.rb rename to spec/impl/event_sender_spec.rb index a8325ff1..c79855bc 100644 --- a/spec/event_sender_spec.rb +++ b/spec/impl/event_sender_spec.rb @@ -7,7 +7,7 @@ module LaunchDarkly module Impl - describe EventSender do + describe EventSender, flaky: true do subject { EventSender } let(:sdk_key) { "sdk_key" } @@ -132,7 +132,7 @@ def with_sender_and_server ENV["http_proxy"] = nil end - req, body = proxy.await_request_with_body + _, body = proxy.await_request_with_body expect(body).to eq fake_data end end @@ -141,7 +141,7 @@ def with_sender_and_server it "handles recoverable error #{status}" do with_sender_and_server do |es, server| req_count = 0 - server.setup_response("/bulk") do |req, res| + server.setup_response("/bulk") do |_, res| req_count = req_count + 1 res.status = req_count == 2 ? 200 : status end @@ -166,7 +166,7 @@ def with_sender_and_server it "only retries error #{status} once" do with_sender_and_server do |es, server| req_count = 0 - server.setup_response("/bulk") do |req, res| + server.setup_response("/bulk") do |_, res| req_count = req_count + 1 res.status = req_count == 3 ? 200 : status end @@ -190,7 +190,7 @@ def with_sender_and_server [401, 403].each do |status| it "gives up after unrecoverable error #{status}" do with_sender_and_server do |es, server| - server.setup_response("/bulk") do |req, res| + server.setup_response("/bulk") do |_, res| res.status = status end diff --git a/spec/impl/event_summarizer_spec.rb b/spec/impl/event_summarizer_spec.rb index d3eb953a..6bfa9574 100644 --- a/spec/impl/event_summarizer_spec.rb +++ b/spec/impl/event_summarizer_spec.rb @@ -29,7 +29,6 @@ module Impl it "tracks start and end dates" do es = subject.new - flag = { key: "key" } event1 = make_eval_event(2000, context, 'flag1') event2 = make_eval_event(1000, context, 'flag1') event3 = make_eval_event(1500, context, 'flag1') @@ -44,8 +43,6 @@ module Impl it "counts events" do es = subject.new - flag1 = { key: "key1", version: 11 } - flag2 = { key: "key2", version: 22 } event1 = make_eval_event(0, context, 'key1', 11, 1, 'value1', nil, 'default1') event2 = make_eval_event(0, context, 'key1', 11, 2, 'value2', nil, 'default1') event3 = make_eval_event(0, context, 'key2', 22, 1, 'value99', nil, 'default2') @@ -54,7 +51,7 @@ module Impl [event1, event2, event3, event4, event5].each { |e| es.summarize_event(e) } data = es.snapshot - expectedCounters = { + expected_counters = { 'key1' => EventSummaryFlagInfo.new( 'default1', { 11 => { @@ -81,7 +78,7 @@ module Impl Set.new(["user"]) ), } - expect(data.counters).to eq expectedCounters + expect(data.counters).to eq expected_counters end end end diff --git a/spec/impl/flag_tracker_spec.rb b/spec/impl/flag_tracker_spec.rb index 61ed5191..5d830a67 100644 --- a/spec/impl/flag_tracker_spec.rb +++ b/spec/impl/flag_tracker_spec.rb @@ -1,77 +1,81 @@ require "spec_helper" require "ldclient-rb/impl/flag_tracker" -describe LaunchDarkly::Impl::FlagTracker do - subject { LaunchDarkly::Impl::FlagTracker } - let(:executor) { SynchronousExecutor.new } - let(:broadcaster) { LaunchDarkly::Impl::Broadcaster.new(executor, $null_log) } +module LaunchDarkly + module Impl + describe FlagTracker do + subject { FlagTracker } + let(:executor) { SynchronousExecutor.new } + let(:broadcaster) { Broadcaster.new(executor, $null_log) } - it "can add and remove listeners as expected" do - listener = ListenerSpy.new + it "can add and remove listeners as expected" do + listener = ListenerSpy.new - tracker = subject.new(broadcaster, Proc.new {}) - tracker.add_listener(listener) + tracker = subject.new(broadcaster, Proc.new {}) + tracker.add_listener(listener) - broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) - broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag2)) + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag2)) - tracker.remove_listener(listener) + tracker.remove_listener(listener) - broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag3)) + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag3)) - expect(listener.statuses.count).to eq(2) - expect(listener.statuses[0].key).to eq(:flag1) - expect(listener.statuses[1].key).to eq(:flag2) - end + expect(listener.statuses.count).to eq(2) + expect(listener.statuses[0].key).to eq(:flag1) + expect(listener.statuses[1].key).to eq(:flag2) + end - describe "flag change listener" do - it "listener is notified when value changes" do - responses = [:initial, :second, :second, :final] - eval_fn = Proc.new { responses.shift } - tracker = subject.new(broadcaster, eval_fn) + describe "flag change listener" do + it "listener is notified when value changes" do + responses = [:initial, :second, :second, :final] + eval_fn = Proc.new { responses.shift } + tracker = subject.new(broadcaster, eval_fn) - listener = ListenerSpy.new - tracker.add_flag_value_change_listener(:flag1, nil, listener) - expect(listener.statuses.count).to eq(0) + listener = ListenerSpy.new + tracker.add_flag_value_change_listener(:flag1, nil, listener) + expect(listener.statuses.count).to eq(0) - broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) - expect(listener.statuses.count).to eq(1) + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) + expect(listener.statuses.count).to eq(1) - # No change was returned here (:second -> :second), so expect no change - broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) - expect(listener.statuses.count).to eq(1) + # No change was returned here (:second -> :second), so expect no change + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) + expect(listener.statuses.count).to eq(1) - broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) - expect(listener.statuses.count).to eq(2) + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) + expect(listener.statuses.count).to eq(2) - expect(listener.statuses[0].key).to eq(:flag1) - expect(listener.statuses[0].old_value).to eq(:initial) - expect(listener.statuses[0].new_value).to eq(:second) + expect(listener.statuses[0].key).to eq(:flag1) + expect(listener.statuses[0].old_value).to eq(:initial) + expect(listener.statuses[0].new_value).to eq(:second) - expect(listener.statuses[1].key).to eq(:flag1) - expect(listener.statuses[1].old_value).to eq(:second) - expect(listener.statuses[1].new_value).to eq(:final) - end + expect(listener.statuses[1].key).to eq(:flag1) + expect(listener.statuses[1].old_value).to eq(:second) + expect(listener.statuses[1].new_value).to eq(:final) + end - it "returns a listener which we can unregister" do - responses = [:initial, :second, :third] - eval_fn = Proc.new { responses.shift } - tracker = subject.new(broadcaster, eval_fn) + it "returns a listener which we can unregister" do + responses = [:initial, :second, :third] + eval_fn = Proc.new { responses.shift } + tracker = subject.new(broadcaster, eval_fn) - listener = ListenerSpy.new - created_listener = tracker.add_flag_value_change_listener(:flag1, nil, listener) - expect(listener.statuses.count).to eq(0) + listener = ListenerSpy.new + created_listener = tracker.add_flag_value_change_listener(:flag1, nil, listener) + expect(listener.statuses.count).to eq(0) - broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) - expect(listener.statuses.count).to eq(1) + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) + expect(listener.statuses.count).to eq(1) - tracker.remove_listener(created_listener) - broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) - expect(listener.statuses.count).to eq(1) + tracker.remove_listener(created_listener) + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) + expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].old_value).to eq(:initial) - expect(listener.statuses[0].new_value).to eq(:second) + expect(listener.statuses[0].old_value).to eq(:initial) + expect(listener.statuses[0].new_value).to eq(:second) + end + end end + end end - diff --git a/spec/impl/repeating_task_spec.rb b/spec/impl/repeating_task_spec.rb index 89f4a408..c970cb5a 100644 --- a/spec/impl/repeating_task_spec.rb +++ b/spec/impl/repeating_task_spec.rb @@ -8,7 +8,7 @@ module LaunchDarkly module Impl describe RepeatingTask do def null_logger - double().as_null_object + double.as_null_object end it "does not start when created" do diff --git a/spec/in_memory_feature_store_spec.rb b/spec/in_memory_feature_store_spec.rb index 2178b22b..6216c2fd 100644 --- a/spec/in_memory_feature_store_spec.rb +++ b/spec/in_memory_feature_store_spec.rb @@ -1,20 +1,22 @@ require "feature_store_spec_base" require "spec_helper" -class InMemoryStoreTester - def create_feature_store - LaunchDarkly::InMemoryFeatureStore.new +module LaunchDarkly + class InMemoryStoreTester + def create_feature_store + InMemoryFeatureStore.new + end end -end -describe LaunchDarkly::InMemoryFeatureStore do - subject { LaunchDarkly::InMemoryFeatureStore } + describe InMemoryFeatureStore do + subject { InMemoryFeatureStore } - include_examples "any_feature_store", InMemoryStoreTester.new + include_examples "any_feature_store", InMemoryStoreTester.new - it "does not provide status monitoring support" do - store = subject.new + it "does not provide status monitoring support" do + store = subject.new - expect(store.monitoring_enabled?).to be false + expect(store.monitoring_enabled?).to be false + end end end diff --git a/spec/integrations/consul_feature_store_spec.rb b/spec/integrations/consul_feature_store_spec.rb index cd3bc983..fdd06833 100644 --- a/spec/integrations/consul_feature_store_spec.rb +++ b/spec/integrations/consul_feature_store_spec.rb @@ -5,59 +5,58 @@ # These tests will all fail if there isn't a local Consul instance running. # They can be enabled with LD_SKIP_DATABASE_TESTS=0 -$consul_base_opts = { - prefix: $my_prefix, - logger: $null_log, -} - -class ConsulStoreTester - def initialize(options) - @options = options - @actual_prefix = @options[:prefix] || LaunchDarkly::Integrations::Consul.default_prefix - end +module LaunchDarkly + module Integrations + class ConsulStoreTester + def initialize(options) + @options = options + @actual_prefix = @options[:prefix] || Consul.default_prefix + end + + def clear_data + Diplomat::Kv.delete(@actual_prefix + '/', recurse: true) + end + + def create_feature_store + Consul.new_feature_store(@options) + end + end - def clear_data - Diplomat::Kv.delete(@actual_prefix + '/', recurse: true) - end - def create_feature_store - LaunchDarkly::Integrations::Consul.new_feature_store(@options) - end -end + describe "Consul feature store" do + break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' + before do + Diplomat.configuration = Diplomat::Configuration.new + end -describe "Consul feature store" do - break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' + include_examples "persistent_feature_store", ConsulStoreTester - before do - Diplomat.configuration = Diplomat::Configuration.new - end + it "should have monitoring enabled and defaults to available" do + tester = ConsulStoreTester.new({ logger: $null_logger }) - include_examples "persistent_feature_store", ConsulStoreTester + ensure_stop(tester.create_feature_store) do |store| + expect(store.monitoring_enabled?).to be true + expect(store.available?).to be true + end + end - it "should have monitoring enabled and defaults to available" do - tester = ConsulStoreTester.new({ logger: $null_logger }) + it "can detect that a non-existent store is not available" do + Diplomat.configure do |config| + config.url = 'http://i-mean-what-are-the-odds:13579' + config.options[:request] ||= {} + # Short timeout so we don't delay the tests too long + config.options[:request][:timeout] = 0.1 + end + tester = ConsulStoreTester.new({ consul_config: Diplomat.configuration }) - ensure_stop(tester.create_feature_store) do |store| - expect(store.monitoring_enabled?).to be true - expect(store.available?).to be true - end - end + ensure_stop(tester.create_feature_store) do |store| + expect(store.available?).to be false + end + end - it "can detect that a non-existent store is not available" do - Diplomat.configure do |config| - config.url = 'http://i-mean-what-are-the-odds:13579' - config.options[:request] ||= {} - # Short timeout so we don't delay the tests too long - config.options[:request][:timeout] = 0.1 end - tester = ConsulStoreTester.new({ consul_config: Diplomat.configuration }) - ensure_stop(tester.create_feature_store) do |store| - expect(store.available?).to be false - end + # There isn't a Big Segments integration for Consul. end - end - -# There isn't a Big Segments integration for Consul. diff --git a/spec/integrations/dynamodb_stores_spec.rb b/spec/integrations/dynamodb_stores_spec.rb index 68232285..53f611a3 100644 --- a/spec/integrations/dynamodb_stores_spec.rb +++ b/spec/integrations/dynamodb_stores_spec.rb @@ -8,166 +8,170 @@ $DynamoDBBigSegmentStore = LaunchDarkly::Impl::Integrations::DynamoDB::DynamoDBBigSegmentStore -class DynamoDBStoreTester - TABLE_NAME = 'LD_DYNAMODB_TEST_TABLE' - DYNAMODB_OPTS = { - credentials: Aws::Credentials.new("key", "secret"), - region: "us-east-1", - endpoint: "http://localhost:8000", - } - FEATURE_STORE_BASE_OPTS = { - dynamodb_opts: DYNAMODB_OPTS, - prefix: 'testprefix', - logger: $null_log, - } - - def initialize(options = {}) - @options = options.clone - @options[:dynamodb_opts] = DYNAMODB_OPTS unless @options.key? :dynamodb_opts - @actual_prefix = options[:prefix] ? "#{options[:prefix]}:" : "" - end - - def self.create_test_client - Aws::DynamoDB::Client.new(DYNAMODB_OPTS) - end +module LaunchDarkly + module Integrations + class DynamoDBStoreTester + TABLE_NAME = 'LD_DYNAMODB_TEST_TABLE' + DYNAMODB_OPTS = { + credentials: Aws::Credentials.new("key", "secret"), + region: "us-east-1", + endpoint: "http://localhost:8000", + } + FEATURE_STORE_BASE_OPTS = { + dynamodb_opts: DYNAMODB_OPTS, + prefix: 'testprefix', + logger: $null_log, + } - def self.create_table_if_necessary - client = create_test_client - begin - client.describe_table({ table_name: TABLE_NAME }) - return # no error, table exists - rescue Aws::DynamoDB::Errors::ResourceNotFoundException - # fall through to code below - we'll create the table - end + def initialize(options = {}) + @options = options.clone + @options[:dynamodb_opts] = DYNAMODB_OPTS unless @options.key? :dynamodb_opts + @actual_prefix = options[:prefix] ? "#{options[:prefix]}:" : "" + end - req = { - table_name: TABLE_NAME, - key_schema: [ - { attribute_name: "namespace", key_type: "HASH" }, - { attribute_name: "key", key_type: "RANGE" }, - ], - attribute_definitions: [ - { attribute_name: "namespace", attribute_type: "S" }, - { attribute_name: "key", attribute_type: "S" }, - ], - provisioned_throughput: { - read_capacity_units: 1, - write_capacity_units: 1, - }, - } - client.create_table(req) - - # When DynamoDB creates a table, it may not be ready to use immediately - end + def self.create_test_client + Aws::DynamoDB::Client.new(DYNAMODB_OPTS) + end - def clear_data - client = self.class.create_test_client - items_to_delete = [] - req = { - table_name: TABLE_NAME, - projection_expression: '#namespace, #key', - expression_attribute_names: { - '#namespace' => 'namespace', - '#key' => 'key', - }, - } - while true - resp = client.scan(req) - resp.items.each do |item| - if !@actual_prefix || item["namespace"].start_with?(@actual_prefix) - items_to_delete.push(item) + def self.create_table_if_necessary + client = create_test_client + begin + client.describe_table({ table_name: TABLE_NAME }) + return # no error, table exists + rescue Aws::DynamoDB::Errors::ResourceNotFoundException + # fall through to code below - we'll create the table end + + req = { + table_name: TABLE_NAME, + key_schema: [ + { attribute_name: "namespace", key_type: "HASH" }, + { attribute_name: "key", key_type: "RANGE" }, + ], + attribute_definitions: [ + { attribute_name: "namespace", attribute_type: "S" }, + { attribute_name: "key", attribute_type: "S" }, + ], + provisioned_throughput: { + read_capacity_units: 1, + write_capacity_units: 1, + }, + } + client.create_table(req) + + # When DynamoDB creates a table, it may not be ready to use immediately end - break if resp.last_evaluated_key.nil? || resp.last_evaluated_key.length == 0 - req.exclusive_start_key = resp.last_evaluated_key - end - requests = items_to_delete.map do |item| - { delete_request: { key: item } } - end - LaunchDarkly::Impl::Integrations::DynamoDB::DynamoDBUtil.batch_write_requests(client, TABLE_NAME, requests) - end - def create_feature_store - LaunchDarkly::Integrations::DynamoDB::new_feature_store(TABLE_NAME, @options) - end + def clear_data + client = self.class.create_test_client + items_to_delete = [] + req = { + table_name: TABLE_NAME, + projection_expression: '#namespace, #key', + expression_attribute_names: { + '#namespace' => 'namespace', + '#key' => 'key', + }, + } + while true + resp = client.scan(req) + resp.items.each do |item| + if !@actual_prefix || item["namespace"].start_with?(@actual_prefix) + items_to_delete.push(item) + end + end + break if resp.last_evaluated_key.nil? || resp.last_evaluated_key.length == 0 + req.exclusive_start_key = resp.last_evaluated_key + end + requests = items_to_delete.map do |item| + { delete_request: { key: item } } + end + LaunchDarkly::Impl::Integrations::DynamoDB::DynamoDBUtil.batch_write_requests(client, TABLE_NAME, requests) + end - def create_big_segment_store - LaunchDarkly::Integrations::DynamoDB::new_big_segment_store(TABLE_NAME, @options) - end + def create_feature_store + LaunchDarkly::Integrations::DynamoDB::new_feature_store(TABLE_NAME, @options) + end - def set_big_segments_metadata(metadata) - client = self.class.create_test_client - key = @actual_prefix + $DynamoDBBigSegmentStore::KEY_METADATA - client.put_item( - table_name: TABLE_NAME, - item: { - "namespace" => key, - "key" => key, - $DynamoDBBigSegmentStore::ATTR_SYNC_TIME => metadata.last_up_to_date, - } - ) - end + def create_big_segment_store + LaunchDarkly::Integrations::DynamoDB::new_big_segment_store(TABLE_NAME, @options) + end - def set_big_segments(context_hash, includes, excludes) - client = self.class.create_test_client - sets = { - $DynamoDBBigSegmentStore::ATTR_INCLUDED => Set.new(includes), - $DynamoDBBigSegmentStore::ATTR_EXCLUDED => Set.new(excludes), - } - sets.each do |attr_name, values| - unless values.empty? - client.update_item( + def set_big_segments_metadata(metadata) + client = self.class.create_test_client + key = @actual_prefix + $DynamoDBBigSegmentStore::KEY_METADATA + client.put_item( table_name: TABLE_NAME, - key: { - "namespace" => @actual_prefix + $DynamoDBBigSegmentStore::KEY_CONTEXT_DATA, - "key" => context_hash, - }, - update_expression: "ADD #{attr_name} :value", - expression_attribute_values: { - ":value" => values, + item: { + "namespace" => key, + "key" => key, + $DynamoDBBigSegmentStore::ATTR_SYNC_TIME => metadata.last_up_to_date, } ) end + + def set_big_segments(context_hash, includes, excludes) + client = self.class.create_test_client + sets = { + $DynamoDBBigSegmentStore::ATTR_INCLUDED => Set.new(includes), + $DynamoDBBigSegmentStore::ATTR_EXCLUDED => Set.new(excludes), + } + sets.each do |attr_name, values| + unless values.empty? + client.update_item( + table_name: TABLE_NAME, + key: { + "namespace" => @actual_prefix + $DynamoDBBigSegmentStore::KEY_CONTEXT_DATA, + "key" => context_hash, + }, + update_expression: "ADD #{attr_name} :value", + expression_attribute_values: { + ":value" => values, + } + ) + end + end + end end - end -end -describe "DynamoDB feature store" do - break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' + describe "DynamoDB feature store" do + break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' - DynamoDBStoreTester.create_table_if_necessary + DynamoDBStoreTester.create_table_if_necessary - include_examples "persistent_feature_store", DynamoDBStoreTester + include_examples "persistent_feature_store", DynamoDBStoreTester - it "should have monitoring enabled and defaults to available" do - tester = DynamoDBStoreTester.new({ logger: $null_logger }) + it "should have monitoring enabled and defaults to available" do + tester = DynamoDBStoreTester.new({ logger: $null_logger }) - ensure_stop(tester.create_feature_store) do |store| - expect(store.monitoring_enabled?).to be true - expect(store.available?).to be true - end - end + ensure_stop(tester.create_feature_store) do |store| + expect(store.monitoring_enabled?).to be true + expect(store.available?).to be true + end + end - it "can detect that a non-existent store is not available" do - options = DynamoDBStoreTester::DYNAMODB_OPTS.clone - options[:endpoint] = 'http://i-mean-what-are-the-odds:13579' - options[:retry_limit] = 0 - options[:http_open_timeout] = 0.1 + it "can detect that a non-existent store is not available" do + options = DynamoDBStoreTester::DYNAMODB_OPTS.clone + options[:endpoint] = 'http://i-mean-what-are-the-odds:13579' + options[:retry_limit] = 0 + options[:http_open_timeout] = 0.1 - # Short timeout so we don't delay the tests too long - tester = DynamoDBStoreTester.new({ dynamodb_opts: options, logger: $null_logger }) + # Short timeout so we don't delay the tests too long + tester = DynamoDBStoreTester.new({ dynamodb_opts: options, logger: $null_logger }) - ensure_stop(tester.create_feature_store) do |store| - expect(store.available?).to be false + ensure_stop(tester.create_feature_store) do |store| + expect(store.available?).to be false + end + end end - end -end -describe "DynamoDB big segment store" do - break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' + describe "DynamoDB big segment store" do + break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' - DynamoDBStoreTester.create_table_if_necessary + DynamoDBStoreTester.create_table_if_necessary - include_examples "big_segment_store", DynamoDBStoreTester + include_examples "big_segment_store", DynamoDBStoreTester + end + end end diff --git a/spec/integrations/file_data_source_spec.rb b/spec/integrations/file_data_source_spec.rb index 976d362b..97704036 100644 --- a/spec/integrations/file_data_source_spec.rb +++ b/spec/integrations/file_data_source_spec.rb @@ -9,70 +9,72 @@ def []=(key, value) end end -describe LaunchDarkly::Integrations::FileData do - let(:full_flag_1_key) { "flag1" } - let(:full_flag_1_value) { "on" } - let(:flag_value_1_key) { "flag2" } - let(:flag_value_1) { "value2" } - let(:all_flag_keys) { [ full_flag_1_key.to_sym, flag_value_1_key.to_sym ] } - let(:full_segment_1_key) { "seg1" } - let(:all_segment_keys) { [ full_segment_1_key.to_sym ] } - - let(:invalid_json) { "My invalid JSON" } - let(:flag_only_json) { <<-EOF - { - "flags": { - "flag1": { - "key": "flag1", - "on": true, - "fallthrough": { - "variation": 2 - }, - "variations": [ "fall", "off", "on" ] - } +module LaunchDarkly + module Integrations + describe FileData do + let(:full_flag_1_key) { "flag1" } + let(:full_flag_1_value) { "on" } + let(:flag_value_1_key) { "flag2" } + let(:flag_value_1) { "value2" } + let(:all_flag_keys) { [ full_flag_1_key.to_sym, flag_value_1_key.to_sym ] } + let(:full_segment_1_key) { "seg1" } + let(:all_segment_keys) { [ full_segment_1_key.to_sym ] } + + let(:invalid_json) { "My invalid JSON" } + let(:flag_only_json) { <<-EOF +{ + "flags": { + "flag1": { + "key": "flag1", + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] } } +} EOF - } - - let(:segment_only_json) { <<-EOF - { - "segments": { - "seg1": { - "key": "seg1", - "include": ["user1"] } + + let(:segment_only_json) { <<-EOF +{ + "segments": { + "seg1": { + "key": "seg1", + "include": ["user1"] } } +} EOF - } - - let(:all_properties_json) { <<-EOF - { - "flags": { - "flag1": { - "key": "flag1", - "on": true, - "fallthrough": { - "variation": 2 - }, - "variations": [ "fall", "off", "on" ] - } - }, - "flagValues": { - "flag2": "value2" - }, - "segments": { - "seg1": { - "key": "seg1", - "include": ["user1"] } + + let(:all_properties_json) { <<-EOF +{ + "flags": { + "flag1": { + "key": "flag1", + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] + } + }, + "flagValues": { + "flag2": "value2" + }, + "segments": { + "seg1": { + "key": "seg1", + "include": ["user1"] } } +} EOF - } + } - let(:all_properties_yaml) { <<-EOF + let(:all_properties_yaml) { <<-EOF --- flags: flag1: @@ -85,236 +87,238 @@ def []=(key, value) key: seg1 include: ["user1"] EOF - } + } - let(:unsafe_yaml) { <<-EOF + let(:unsafe_yaml) { <<-EOF --- !ruby/hash:BadClassWeShouldNotInstantiate foo: bar EOF - } - - let(:bad_file_path) { "no-such-file" } - - before do - @config = LaunchDarkly::Config.new(logger: $null_log) - @store = @config.feature_store - - @executor = SynchronousExecutor.new - @status_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@executor, $null_log) - @flag_change_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@executor, $null_log) - @config.data_source_update_sink = LaunchDarkly::Impl::DataSource::UpdateSink.new(@store, @status_broadcaster, @flag_change_broadcaster) - - @tmp_dir = Dir.mktmpdir - end - - after do - FileUtils.rm_rf(@tmp_dir) - end - - def make_temp_file(content) - # Note that we don't create our files in the default temp file directory, but rather in an empty directory - # that we made. That's because (depending on the platform) the temp file directory may contain huge numbers - # of files, which can make the file watcher perform poorly enough to break the tests. - file = Tempfile.new('flags', @tmp_dir) - IO.write(file, content) - file - end - - def with_data_source(options, initialize_to_valid = false) - factory = LaunchDarkly::Integrations::FileData.data_source(options) - - if initialize_to_valid - # If the update sink receives an interrupted signal when the state is - # still initializing, it will continue staying in the initializing phase. - # Therefore, we set the state to valid before this test so we can - # determine if the interrupted signal is actually generated. - @config.data_source_update_sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - end - - ds = factory.call('', @config) - - begin - yield ds - ensure - ds.stop - end - end - - it "doesn't load flags prior to start" do - file = make_temp_file('{"flagValues":{"key":"value"}}') - with_data_source({ paths: [ file.path ] }) do |ds| - expect(@store.initialized?).to eq(false) - expect(@store.all(LaunchDarkly::FEATURES)).to eq({}) - expect(@store.all(LaunchDarkly::SEGMENTS)).to eq({}) - end - end - - it "loads flags on start - from JSON" do - file = make_temp_file(all_properties_json) - with_data_source({ paths: [ file.path ] }) do |ds| - listener = ListenerSpy.new - @status_broadcaster.add_listener(listener) - - ds.start - expect(@store.initialized?).to eq(true) - expect(@store.all(LaunchDarkly::FEATURES).keys).to eq(all_flag_keys) - expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq(all_segment_keys) - - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) - end - end - - it "loads flags on start - from YAML" do - file = make_temp_file(all_properties_yaml) - with_data_source({ paths: [ file.path ] }) do |ds| - ds.start - expect(@store.initialized?).to eq(true) - expect(@store.all(LaunchDarkly::FEATURES).keys).to eq(all_flag_keys) - expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq(all_segment_keys) - end - end - - it "does not allow Ruby objects in YAML" do - # This tests for the vulnerability described here: https://trailofbits.github.io/rubysec/yaml/index.html - # The file we're loading contains a hash with a custom Ruby class, BadClassWeShouldNotInstantiate (see top - # of file). If we're not loading in safe mode, it will create an instance of that class and call its []= - # method, which we've defined to set $created_bad_class to true. In safe mode, it refuses to parse this file. - file = make_temp_file(unsafe_yaml) - with_data_source({ paths: [file.path ] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(ds.initialized?).to eq(false) - expect($created_bad_class).to eq(false) - end - end - - it "sets start event and initialized on successful load" do - file = make_temp_file(all_properties_json) - with_data_source({ paths: [ file.path ] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(ds.initialized?).to eq(true) - end - end - - it "sets start event and does not set initialized on unsuccessful load" do - with_data_source({ paths: [ bad_file_path ] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(ds.initialized?).to eq(false) - end - end - - it "can load multiple files" do - file1 = make_temp_file(flag_only_json) - file2 = make_temp_file(segment_only_json) - with_data_source({ paths: [ file1.path, file2.path ] }) do |ds| - ds.start - expect(@store.initialized?).to eq(true) - expect(@store.all(LaunchDarkly::FEATURES).keys).to eq([ full_flag_1_key.to_sym ]) - expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([ full_segment_1_key.to_sym ]) - end - end - - it "file loading failure results in interrupted status" do - file1 = make_temp_file(flag_only_json) - file2 = make_temp_file(invalid_json) - with_data_source({ paths: [ file1.path, file2.path ] }, true) do |ds| - listener = ListenerSpy.new - @status_broadcaster.add_listener(listener) - - ds.start - expect(@store.initialized?).to eq(false) - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) - end - end - - it "does not allow duplicate keys" do - file1 = make_temp_file(flag_only_json) - file2 = make_temp_file(flag_only_json) - with_data_source({ paths: [ file1.path, file2.path ] }) do |ds| - ds.start - expect(@store.initialized?).to eq(false) - expect(@store.all(LaunchDarkly::FEATURES).keys).to eq([]) - end - end - - it "does not reload modified file if auto-update is off" do - file = make_temp_file(flag_only_json) - - with_data_source({ paths: [ file.path ] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([]) - - IO.write(file, all_properties_json) - sleep(0.5) - expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([]) - end - end - - def test_auto_reload(options) - file = make_temp_file(flag_only_json) - options[:paths] = [ file.path ] - - with_data_source(options) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([]) - - sleep(1) - IO.write(file, all_properties_json) - - max_time = 10 - ok = wait_for_condition(10) { @store.all(LaunchDarkly::SEGMENTS).keys == all_segment_keys } - expect(ok).to eq(true), "Waited #{max_time}s after modifying file and it did not reload" - end - end - - it "reloads modified file if auto-update is on" do - test_auto_reload({ auto_update: true }) - end - - it "reloads modified file in polling mode" do - test_auto_reload({ auto_update: true, force_polling: true, poll_interval: 0.1 }) - end - - it "evaluates simplified flag with client as expected" do - file = make_temp_file(all_properties_json) - factory = LaunchDarkly::Integrations::FileData.data_source({ paths: file.path }) - config = LaunchDarkly::Config.new(send_events: false, data_source: factory) - client = LaunchDarkly::LDClient.new('sdkKey', config) - - begin - value = client.variation(flag_value_1_key, { key: 'user' }, '') - expect(value).to eq(flag_value_1) - ensure - client.close - end - end - - it "evaluates full flag with client as expected" do - file = make_temp_file(all_properties_json) - factory = LaunchDarkly::Integrations::FileData.data_source({ paths: file.path }) - config = LaunchDarkly::Config.new(send_events: false, data_source: factory) - client = LaunchDarkly::LDClient.new('sdkKey', config) - - begin - value = client.variation(full_flag_1_key, { key: 'user' }, '') - expect(value).to eq(full_flag_1_value) - ensure - client.close - end - end + } - def wait_for_condition(max_time) - deadline = Time.now + max_time - while Time.now < deadline - return true if yield - sleep(0.1) + let(:bad_file_path) { "no-such-file" } + + before do + @config = LaunchDarkly::Config.new(logger: $null_log) + @store = @config.feature_store + + @executor = SynchronousExecutor.new + @status_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@executor, $null_log) + @flag_change_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@executor, $null_log) + @config.data_source_update_sink = LaunchDarkly::Impl::DataSource::UpdateSink.new(@store, @status_broadcaster, @flag_change_broadcaster) + + @tmp_dir = Dir.mktmpdir + end + + after do + FileUtils.rm_rf(@tmp_dir) + end + + def make_temp_file(content) + # Note that we don't create our files in the default temp file directory, but rather in an empty directory + # that we made. That's because (depending on the platform) the temp file directory may contain huge numbers + # of files, which can make the file watcher perform poorly enough to break the tests. + file = Tempfile.new('flags', @tmp_dir) + IO.write(file, content) + file + end + + def with_data_source(options, initialize_to_valid = false) + factory = FileData.data_source(options) + + if initialize_to_valid + # If the update sink receives an interrupted signal when the state is + # still initializing, it will continue staying in the initializing phase. + # Therefore, we set the state to valid before this test so we can + # determine if the interrupted signal is actually generated. + @config.data_source_update_sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) + end + + ds = factory.call('', @config) + + begin + yield ds + ensure + ds.stop + end + end + + it "doesn't load flags prior to start" do + file = make_temp_file('{"flagValues":{"key":"value"}}') + with_data_source({ paths: [ file.path ] }) do |_| + expect(@store.initialized?).to eq(false) + expect(@store.all(LaunchDarkly::FEATURES)).to eq({}) + expect(@store.all(LaunchDarkly::SEGMENTS)).to eq({}) + end + end + + it "loads flags on start - from JSON" do + file = make_temp_file(all_properties_json) + with_data_source({ paths: [ file.path ] }) do |ds| + listener = ListenerSpy.new + @status_broadcaster.add_listener(listener) + + ds.start + expect(@store.initialized?).to eq(true) + expect(@store.all(LaunchDarkly::FEATURES).keys).to eq(all_flag_keys) + expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq(all_segment_keys) + + expect(listener.statuses.count).to eq(1) + expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + end + end + + it "loads flags on start - from YAML" do + file = make_temp_file(all_properties_yaml) + with_data_source({ paths: [ file.path ] }) do |ds| + ds.start + expect(@store.initialized?).to eq(true) + expect(@store.all(LaunchDarkly::FEATURES).keys).to eq(all_flag_keys) + expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq(all_segment_keys) + end + end + + it "does not allow Ruby objects in YAML" do + # This tests for the vulnerability described here: https://trailofbits.github.io/rubysec/yaml/index.html + # The file we're loading contains a hash with a custom Ruby class, BadClassWeShouldNotInstantiate (see top + # of file). If we're not loading in safe mode, it will create an instance of that class and call its []= + # method, which we've defined to set $created_bad_class to true. In safe mode, it refuses to parse this file. + file = make_temp_file(unsafe_yaml) + with_data_source({ paths: [file.path ] }) do |ds| + event = ds.start + expect(event.set?).to eq(true) + expect(ds.initialized?).to eq(false) + expect($created_bad_class).to eq(false) + end + end + + it "sets start event and initialized on successful load" do + file = make_temp_file(all_properties_json) + with_data_source({ paths: [ file.path ] }) do |ds| + event = ds.start + expect(event.set?).to eq(true) + expect(ds.initialized?).to eq(true) + end + end + + it "sets start event and does not set initialized on unsuccessful load" do + with_data_source({ paths: [ bad_file_path ] }) do |ds| + event = ds.start + expect(event.set?).to eq(true) + expect(ds.initialized?).to eq(false) + end + end + + it "can load multiple files" do + file1 = make_temp_file(flag_only_json) + file2 = make_temp_file(segment_only_json) + with_data_source({ paths: [ file1.path, file2.path ] }) do |ds| + ds.start + expect(@store.initialized?).to eq(true) + expect(@store.all(LaunchDarkly::FEATURES).keys).to eq([ full_flag_1_key.to_sym ]) + expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([ full_segment_1_key.to_sym ]) + end + end + + it "file loading failure results in interrupted status" do + file1 = make_temp_file(flag_only_json) + file2 = make_temp_file(invalid_json) + with_data_source({ paths: [ file1.path, file2.path ] }, true) do |ds| + listener = ListenerSpy.new + @status_broadcaster.add_listener(listener) + + ds.start + expect(@store.initialized?).to eq(false) + expect(listener.statuses.count).to eq(1) + expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + end + end + + it "does not allow duplicate keys" do + file1 = make_temp_file(flag_only_json) + file2 = make_temp_file(flag_only_json) + with_data_source({ paths: [ file1.path, file2.path ] }) do |ds| + ds.start + expect(@store.initialized?).to eq(false) + expect(@store.all(LaunchDarkly::FEATURES).keys).to eq([]) + end + end + + it "does not reload modified file if auto-update is off" do + file = make_temp_file(flag_only_json) + + with_data_source({ paths: [ file.path ] }) do |ds| + event = ds.start + expect(event.set?).to eq(true) + expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([]) + + IO.write(file, all_properties_json) + sleep(0.5) + expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([]) + end + end + + def test_auto_reload(options) + file = make_temp_file(flag_only_json) + options[:paths] = [ file.path ] + + with_data_source(options) do |ds| + event = ds.start + expect(event.set?).to eq(true) + expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([]) + + sleep(1) + IO.write(file, all_properties_json) + + max_time = 10 + ok = wait_for_condition(10) { @store.all(LaunchDarkly::SEGMENTS).keys == all_segment_keys } + expect(ok).to eq(true), "Waited #{max_time}s after modifying file and it did not reload" + end + end + + it "reloads modified file if auto-update is on" do + test_auto_reload({ auto_update: true }) + end + + it "reloads modified file in polling mode" do + test_auto_reload({ auto_update: true, force_polling: true, poll_interval: 0.1 }) + end + + it "evaluates simplified flag with client as expected" do + file = make_temp_file(all_properties_json) + factory = FileData.data_source({ paths: file.path }) + config = LaunchDarkly::Config.new(send_events: false, data_source: factory) + client = LaunchDarkly::LDClient.new('sdkKey', config) + + begin + value = client.variation(flag_value_1_key, { key: 'user' }, '') + expect(value).to eq(flag_value_1) + ensure + client.close + end + end + + it "evaluates full flag with client as expected" do + file = make_temp_file(all_properties_json) + factory = FileData.data_source({ paths: file.path }) + config = LaunchDarkly::Config.new(send_events: false, data_source: factory) + client = LaunchDarkly::LDClient.new('sdkKey', config) + + begin + value = client.variation(full_flag_1_key, { key: 'user' }, '') + expect(value).to eq(full_flag_1_value) + ensure + client.close + end + end + + def wait_for_condition(max_time) + deadline = Time.now + max_time + while Time.now < deadline + return true if yield + sleep(0.1) + end + false + end end - false end end diff --git a/spec/integrations/redis_stores_spec.rb b/spec/integrations/redis_stores_spec.rb index 1b87ee4e..1f59469a 100644 --- a/spec/integrations/redis_stores_spec.rb +++ b/spec/integrations/redis_stores_spec.rb @@ -18,153 +18,155 @@ def with_redis_test_client end -class RedisStoreTester - def initialize(options) - @options = options - @actual_prefix = @options[:prefix] ||LaunchDarkly::Integrations::Redis.default_prefix - end - - def clear_data - with_redis_test_client do |client| - keys = client.keys("#{@actual_prefix}:*") - keys.each { |key| client.del(key) } - end - end +module LaunchDarkly + module Integrations + class RedisStoreTester + def initialize(options) + @options = options + @actual_prefix = @options[:prefix] || Redis.default_prefix + end - def create_feature_store - LaunchDarkly::Integrations::Redis::new_feature_store(@options) - end + def clear_data + with_redis_test_client do |client| + keys = client.keys("#{@actual_prefix}:*") + keys.each { |key| client.del(key) } + end + end - def create_big_segment_store - LaunchDarkly::Integrations::Redis.new_big_segment_store(@options) - end + def create_feature_store + Redis::new_feature_store(@options) + end - def set_big_segments_metadata(metadata) - with_redis_test_client do |client| - client.set(@actual_prefix + $RedisBigSegmentStore::KEY_LAST_UP_TO_DATE, - metadata.last_up_to_date.nil? ? "" : metadata.last_up_to_date.to_s) - end - end + def create_big_segment_store + Redis.new_big_segment_store(@options) + end - def set_big_segments(context_hash, includes, excludes) - with_redis_test_client do |client| - includes.each do |ref| - client.sadd?(@actual_prefix + $RedisBigSegmentStore::KEY_CONTEXT_INCLUDE + context_hash, ref) + def set_big_segments_metadata(metadata) + with_redis_test_client do |client| + client.set(@actual_prefix + $RedisBigSegmentStore::KEY_LAST_UP_TO_DATE, + metadata.last_up_to_date.nil? ? "" : metadata.last_up_to_date.to_s) + end end - excludes.each do |ref| - client.sadd?(@actual_prefix + $RedisBigSegmentStore::KEY_CONTEXT_EXCLUDE + context_hash, ref) + + def set_big_segments(context_hash, includes, excludes) + with_redis_test_client do |client| + includes.each do |ref| + client.sadd?(@actual_prefix + $RedisBigSegmentStore::KEY_CONTEXT_INCLUDE + context_hash, ref) + end + excludes.each do |ref| + client.sadd?(@actual_prefix + $RedisBigSegmentStore::KEY_CONTEXT_EXCLUDE + context_hash, ref) + end + end end end - end -end -describe "Redis feature store" do - break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' + describe "Redis feature store" do + break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' - include_examples "persistent_feature_store", RedisStoreTester + include_examples "persistent_feature_store", RedisStoreTester - def make_concurrent_modifier_test_hook(other_client, flag, start_version, end_version) - test_hook = Object.new - version_counter = start_version - expect(test_hook).to receive(:before_update_transaction) { |base_key, key| - if version_counter <= end_version - new_flag = flag.clone - new_flag[:version] = version_counter - other_client.hset(base_key, key, new_flag.to_json) - version_counter = version_counter + 1 + def make_concurrent_modifier_test_hook(other_client, flag, start_version, end_version) + test_hook = Object.new + version_counter = start_version + expect(test_hook).to receive(:before_update_transaction) { |base_key, key| + if version_counter <= end_version + new_flag = flag.clone + new_flag[:version] = version_counter + other_client.hset(base_key, key, new_flag.to_json) + version_counter = version_counter + 1 + end + }.at_least(:once) + test_hook end - }.at_least(:once) - test_hook - end - tester = RedisStoreTester.new({ logger: $null_logger }) + it "should have monitoring enabled and defaults to available" do + tester = RedisStoreTester.new({ logger: $null_logger }) - it "should have monitoring enabled and defaults to available" do - tester = RedisStoreTester.new({ logger: $null_logger }) - - ensure_stop(tester.create_feature_store) do |store| - expect(store.monitoring_enabled?).to be true - expect(store.available?).to be true - end - end - - it "can detect that a non-existent store is not available" do - # Short timeout so we don't delay the tests too long - tester = RedisStoreTester.new({ redis_opts: { url: "redis://i-mean-what-are-the-odds:13579", timeout: 0.1 }, logger: $null_logger }) + ensure_stop(tester.create_feature_store) do |store| + expect(store.monitoring_enabled?).to be true + expect(store.available?).to be true + end + end - ensure_stop(tester.create_feature_store) do |store| - expect(store.available?).to be false - end - end + it "can detect that a non-existent store is not available" do + # Short timeout so we don't delay the tests too long + tester = RedisStoreTester.new({ redis_opts: { url: "redis://i-mean-what-are-the-odds:13579", timeout: 0.1 }, logger: $null_logger }) - it "handles upsert race condition against external client with lower version" do - with_redis_test_client do |other_client| - flag = { key: "foo", version: 1 } - test_hook = make_concurrent_modifier_test_hook(other_client, flag, 2, 4) - tester = RedisStoreTester.new({ test_hook: test_hook, logger: $null_logger }) + ensure_stop(tester.create_feature_store) do |store| + expect(store.available?).to be false + end + end - ensure_stop(tester.create_feature_store) do |store| - store.init(LaunchDarkly::FEATURES => { flag[:key] => flag }) + it "handles upsert race condition against external client with lower version" do + with_redis_test_client do |other_client| + flag = { key: "foo", version: 1 } + test_hook = make_concurrent_modifier_test_hook(other_client, flag, 2, 4) + tester = RedisStoreTester.new({ test_hook: test_hook, logger: $null_logger }) + + ensure_stop(tester.create_feature_store) do |store| + store.init(LaunchDarkly::FEATURES => { flag[:key] => flag }) + + my_ver = { key: "foo", version: 10 } + store.upsert(LaunchDarkly::FEATURES, my_ver) + result = store.get(LaunchDarkly::FEATURES, flag[:key]) + expect(result[:version]).to eq 10 + end + end + end - my_ver = { key: "foo", version: 10 } - store.upsert(LaunchDarkly::FEATURES, my_ver) - result = store.get(LaunchDarkly::FEATURES, flag[:key]) - expect(result[:version]).to eq 10 + it "handles upsert race condition against external client with higher version" do + with_redis_test_client do |other_client| + flag = { key: "foo", version: 1 } + test_hook = make_concurrent_modifier_test_hook(other_client, flag, 3, 3) + tester = RedisStoreTester.new({ test_hook: test_hook, logger: $null_logger }) + + ensure_stop(tester.create_feature_store) do |store| + store.init(LaunchDarkly::FEATURES => { flag[:key] => flag }) + + my_ver = { key: "foo", version: 2 } + store.upsert(LaunchDarkly::FEATURES, my_ver) + result = store.get(LaunchDarkly::FEATURES, flag[:key]) + expect(result[:version]).to eq 3 + end + end end - end - end - it "handles upsert race condition against external client with higher version" do - with_redis_test_client do |other_client| - flag = { key: "foo", version: 1 } - test_hook = make_concurrent_modifier_test_hook(other_client, flag, 3, 3) - tester = RedisStoreTester.new({ test_hook: test_hook, logger: $null_logger }) + it "shuts down a custom Redis pool by default" do + unowned_pool = ConnectionPool.new(size: 1, timeout: 1) { ::Redis.new({ url: "redis://localhost:6379" }) } + tester = RedisStoreTester.new({ pool: unowned_pool, logger: $null_logger }) + store = tester.create_feature_store - ensure_stop(tester.create_feature_store) do |store| - store.init(LaunchDarkly::FEATURES => { flag[:key] => flag }) + begin + store.init(LaunchDarkly::FEATURES => { }) + store.stop - my_ver = { key: "foo", version: 2 } - store.upsert(LaunchDarkly::FEATURES, my_ver) - result = store.get(LaunchDarkly::FEATURES, flag[:key]) - expect(result[:version]).to eq 3 + expect { unowned_pool.with {} }.to raise_error(ConnectionPool::PoolShuttingDownError) + ensure + unowned_pool.shutdown { |conn| conn.close } + end end - end - end - it "shuts down a custom Redis pool by default" do - unowned_pool = ConnectionPool.new(size: 1, timeout: 1) { Redis.new({ url: "redis://localhost:6379" }) } - tester = RedisStoreTester.new({ pool: unowned_pool, logger: $null_logger }) - store = tester.create_feature_store + it "doesn't shut down a custom Redis pool if pool_shutdown_on_close = false" do + unowned_pool = ConnectionPool.new(size: 1, timeout: 1) { ::Redis.new({ url: "redis://localhost:6379" }) } + tester = RedisStoreTester.new({ pool: unowned_pool, pool_shutdown_on_close: false, logger: $null_logger }) + store = tester.create_feature_store - begin - store.init(LaunchDarkly::FEATURES => { }) - store.stop + begin + store.init(LaunchDarkly::FEATURES => { }) + store.stop - expect { unowned_pool.with {} }.to raise_error(ConnectionPool::PoolShuttingDownError) - ensure - unowned_pool.shutdown { |conn| conn.close } + expect { unowned_pool.with {} }.not_to raise_error + ensure + unowned_pool.shutdown { |conn| conn.close } + end + end end - end - it "doesn't shut down a custom Redis pool if pool_shutdown_on_close = false" do - unowned_pool = ConnectionPool.new(size: 1, timeout: 1) { Redis.new({ url: "redis://localhost:6379" }) } - tester = RedisStoreTester.new({ pool: unowned_pool, pool_shutdown_on_close: false, logger: $null_logger }) - store = tester.create_feature_store + describe "Redis big segment store" do + break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' - begin - store.init(LaunchDarkly::FEATURES => { }) - store.stop - - expect { unowned_pool.with {} }.not_to raise_error - ensure - unowned_pool.shutdown { |conn| conn.close } + include_examples "big_segment_store", RedisStoreTester end end end - -describe "Redis big segment store" do - break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' - - include_examples "big_segment_store", RedisStoreTester -end diff --git a/spec/integrations/store_wrapper_spec.rb b/spec/integrations/store_wrapper_spec.rb deleted file mode 100644 index 552fe5a4..00000000 --- a/spec/integrations/store_wrapper_spec.rb +++ /dev/null @@ -1,295 +0,0 @@ -require "spec_helper" - -describe LaunchDarkly::Integrations::Util::CachingStoreWrapper do - subject { LaunchDarkly::Integrations::Util::CachingStoreWrapper } - - THINGS = { namespace: "things" } - - it "monitoring enabled if available is defined" do - [true, false].each do |expected| - core = double - allow(core).to receive(:available?).and_return(expected) - wrapper = subject.new(core, {}) - - expect(wrapper.monitoring_enabled?).to be true - expect(wrapper.available?).to be expected - end - end - - it "available is false if core doesn't support monitoring" do - core = double - wrapper = subject.new(core, {}) - - expect(wrapper.monitoring_enabled?).to be false - expect(wrapper.available?).to be false - end - - shared_examples "tests" do |cached| - opts = cached ? { expiration: 30 } : { expiration: 0 } - - it "gets item" do - core = MockCore.new - wrapper = subject.new(core, opts) - key = "flag" - itemv1 = { key: key, version: 1 } - itemv2 = { key: key, version: 2 } - - core.force_set(THINGS, itemv1) - expect(wrapper.get(THINGS, key)).to eq itemv1 - - core.force_set(THINGS, itemv2) - expect(wrapper.get(THINGS, key)).to eq (cached ? itemv1 : itemv2) # if cached, we will not see the new underlying value yet - end - - it "gets deleted item" do - core = MockCore.new - wrapper = subject.new(core, opts) - key = "flag" - itemv1 = { key: key, version: 1, deleted: true } - itemv2 = { key: key, version: 2, deleted: false } - - core.force_set(THINGS, itemv1) - expect(wrapper.get(THINGS, key)).to eq nil # item is filtered out because deleted is true - - core.force_set(THINGS, itemv2) - expect(wrapper.get(THINGS, key)).to eq (cached ? nil : itemv2) # if cached, we will not see the new underlying value yet - end - - it "gets missing item" do - core = MockCore.new - wrapper = subject.new(core, opts) - key = "flag" - item = { key: key, version: 1 } - - expect(wrapper.get(THINGS, key)).to eq nil - - core.force_set(THINGS, item) - expect(wrapper.get(THINGS, key)).to eq (cached ? nil : item) # the cache can retain a nil result - end - - it "gets all items" do - core = MockCore.new - wrapper = subject.new(core, opts) - item1 = { key: "flag1", version: 1 } - item2 = { key: "flag2", version: 1 } - - core.force_set(THINGS, item1) - core.force_set(THINGS, item2) - expect(wrapper.all(THINGS)).to eq({ item1[:key] => item1, item2[:key] => item2 }) - - core.force_remove(THINGS, item2[:key]) - expect(wrapper.all(THINGS)).to eq (cached ? - { item1[:key] => item1, item2[:key] => item2 } : - { item1[:key] => item1 }) - end - - it "gets all items filtering out deleted items" do - core = MockCore.new - wrapper = subject.new(core, opts) - item1 = { key: "flag1", version: 1 } - item2 = { key: "flag2", version: 1, deleted: true } - - core.force_set(THINGS, item1) - core.force_set(THINGS, item2) - expect(wrapper.all(THINGS)).to eq({ item1[:key] => item1 }) - end - - it "upserts item successfully" do - core = MockCore.new - wrapper = subject.new(core, opts) - key = "flag" - itemv1 = { key: key, version: 1 } - itemv2 = { key: key, version: 2 } - - wrapper.upsert(THINGS, itemv1) - expect(core.data[THINGS][key]).to eq itemv1 - - wrapper.upsert(THINGS, itemv2) - expect(core.data[THINGS][key]).to eq itemv2 - - # if we have a cache, verify that the new item is now cached by writing a different value - # to the underlying data - Get should still return the cached item - if cached - itemv3 = { key: key, version: 3 } - core.force_set(THINGS, itemv3) - end - - expect(wrapper.get(THINGS, key)).to eq itemv2 - end - - it "deletes item" do - core = MockCore.new - wrapper = subject.new(core, opts) - key = "flag" - itemv1 = { key: key, version: 1 } - itemv2 = { key: key, version: 2, deleted: true } - itemv3 = { key: key, version: 3 } - - core.force_set(THINGS, itemv1) - expect(wrapper.get(THINGS, key)).to eq itemv1 - - wrapper.delete(THINGS, key, 2) - expect(core.data[THINGS][key]).to eq itemv2 - - core.force_set(THINGS, itemv3) # make a change that bypasses the cache - - expect(wrapper.get(THINGS, key)).to eq (cached ? nil : itemv3) - end - end - - context "cached" do - include_examples "tests", true - - cached_opts = { expiration: 30 } - - it "get uses values from init" do - core = MockCore.new - wrapper = subject.new(core, cached_opts) - item1 = { key: "flag1", version: 1 } - item2 = { key: "flag2", version: 1 } - - wrapper.init({ THINGS => { item1[:key] => item1, item2[:key] => item2 } }) - core.force_remove(THINGS, item1[:key]) - - expect(wrapper.get(THINGS, item1[:key])).to eq item1 - end - - it "get all uses values from init" do - core = MockCore.new - wrapper = subject.new(core, cached_opts) - item1 = { key: "flag1", version: 1 } - item2 = { key: "flag2", version: 1 } - - wrapper.init({ THINGS => { item1[:key] => item1, item2[:key] => item2 } }) - core.force_remove(THINGS, item1[:key]) - - expect(wrapper.all(THINGS)).to eq ({ item1[:key] => item1, item2[:key] => item2 }) - end - - it "upsert doesn't update cache if unsuccessful" do - # This is for an upsert where the data in the store has a higher version. In an uncached - # store, this is just a no-op as far as the wrapper is concerned so there's nothing to - # test here. In a cached store, we need to verify that the cache has been refreshed - # using the data that was found in the store. - core = MockCore.new - wrapper = subject.new(core, cached_opts) - key = "flag" - itemv1 = { key: key, version: 1 } - itemv2 = { key: key, version: 2 } - - wrapper.upsert(THINGS, itemv2) - expect(core.data[THINGS][key]).to eq itemv2 - - wrapper.upsert(THINGS, itemv1) - expect(core.data[THINGS][key]).to eq itemv2 # value in store remains the same - - itemv3 = { key: key, version: 3 } - core.force_set(THINGS, itemv3) # bypasses cache so we can verify that itemv2 is in the cache - expect(wrapper.get(THINGS, key)).to eq itemv2 - end - - it "initialized? can cache false result" do - core = MockCore.new - wrapper = subject.new(core, { expiration: 0.2 }) # use a shorter cache TTL for this test - - expect(wrapper.initialized?).to eq false - expect(core.inited_query_count).to eq 1 - - core.inited = true - expect(wrapper.initialized?).to eq false - expect(core.inited_query_count).to eq 1 - - sleep(0.5) - - expect(wrapper.initialized?).to eq true - expect(core.inited_query_count).to eq 2 - - # From this point on it should remain true and the method should not be called - expect(wrapper.initialized?).to eq true - expect(core.inited_query_count).to eq 2 - end - end - - context "uncached" do - include_examples "tests", false - - uncached_opts = { expiration: 0 } - - it "queries internal initialized state only if not already inited" do - core = MockCore.new - wrapper = subject.new(core, uncached_opts) - - expect(wrapper.initialized?).to eq false - expect(core.inited_query_count).to eq 1 - - core.inited = true - expect(wrapper.initialized?).to eq true - expect(core.inited_query_count).to eq 2 - - core.inited = false - expect(wrapper.initialized?).to eq true - expect(core.inited_query_count).to eq 2 - end - - it "does not query internal initialized state if init has been called" do - core = MockCore.new - wrapper = subject.new(core, uncached_opts) - - expect(wrapper.initialized?).to eq false - expect(core.inited_query_count).to eq 1 - - wrapper.init({}) - - expect(wrapper.initialized?).to eq true - expect(core.inited_query_count).to eq 1 - end - end - - class MockCore - def initialize - @data = {} - @inited = false - @inited_query_count = 0 - end - - attr_reader :data - attr_reader :inited_query_count - attr_accessor :inited - - def force_set(kind, item) - @data[kind] = {} unless @data.has_key?(kind) - @data[kind][item[:key]] = item - end - - def force_remove(kind, key) - @data[kind].delete(key) if @data.has_key?(kind) - end - - def init_internal(all_data) - @data = all_data - @inited = true - end - - def get_internal(kind, key) - items = @data[kind] - items.nil? ? nil : items[key] - end - - def get_all_internal(kind) - @data[kind] - end - - def upsert_internal(kind, item) - @data[kind] = {} unless @data.has_key?(kind) - old_item = @data[kind][item[:key]] - return old_item if !old_item.nil? && old_item[:version] >= item[:version] - @data[kind][item[:key]] = item - item - end - - def initialized_internal? - @inited_query_count = @inited_query_count + 1 - @inited - end - end -end diff --git a/spec/integrations/util/store_wrapper_spec.rb b/spec/integrations/util/store_wrapper_spec.rb new file mode 100644 index 00000000..e20f8b21 --- /dev/null +++ b/spec/integrations/util/store_wrapper_spec.rb @@ -0,0 +1,299 @@ +require "spec_helper" + +module LaunchDarkly + module Integrations + describe Util::CachingStoreWrapper do + subject { Util::CachingStoreWrapper } + + THINGS = { namespace: "things" } + + it "monitoring enabled if available is defined" do + [true, false].each do |expected| + core = double + allow(core).to receive(:available?).and_return(expected) + wrapper = subject.new(core, {}) + + expect(wrapper.monitoring_enabled?).to be true + expect(wrapper.available?).to be expected + end + end + + it "available is false if core doesn't support monitoring" do + core = double + wrapper = subject.new(core, {}) + + expect(wrapper.monitoring_enabled?).to be false + expect(wrapper.available?).to be false + end + + shared_examples "tests" do |cached| + opts = cached ? { expiration: 30 } : { expiration: 0 } + + it "gets item" do + core = MockCore.new + wrapper = subject.new(core, opts) + key = "flag" + itemv1 = { key: key, version: 1 } + itemv2 = { key: key, version: 2 } + + core.force_set(THINGS, itemv1) + expect(wrapper.get(THINGS, key)).to eq itemv1 + + core.force_set(THINGS, itemv2) + expect(wrapper.get(THINGS, key)).to eq(cached ? itemv1 : itemv2) # if cached, we will not see the new underlying value yet + end + + it "gets deleted item" do + core = MockCore.new + wrapper = subject.new(core, opts) + key = "flag" + itemv1 = { key: key, version: 1, deleted: true } + itemv2 = { key: key, version: 2, deleted: false } + + core.force_set(THINGS, itemv1) + expect(wrapper.get(THINGS, key)).to eq nil # item is filtered out because deleted is true + + core.force_set(THINGS, itemv2) + expect(wrapper.get(THINGS, key)).to eq(cached ? nil : itemv2) # if cached, we will not see the new underlying value yet + end + + it "gets missing item" do + core = MockCore.new + wrapper = subject.new(core, opts) + key = "flag" + item = { key: key, version: 1 } + + expect(wrapper.get(THINGS, key)).to eq nil + + core.force_set(THINGS, item) + expect(wrapper.get(THINGS, key)).to eq(cached ? nil : item) # the cache can retain a nil result + end + + it "gets all items" do + core = MockCore.new + wrapper = subject.new(core, opts) + item1 = { key: "flag1", version: 1 } + item2 = { key: "flag2", version: 1 } + + core.force_set(THINGS, item1) + core.force_set(THINGS, item2) + expect(wrapper.all(THINGS)).to eq({ item1[:key] => item1, item2[:key] => item2 }) + + core.force_remove(THINGS, item2[:key]) + expect(wrapper.all(THINGS)).to eq(cached ? + { item1[:key] => item1, item2[:key] => item2 } : + { item1[:key] => item1 }) + end + + it "gets all items filtering out deleted items" do + core = MockCore.new + wrapper = subject.new(core, opts) + item1 = { key: "flag1", version: 1 } + item2 = { key: "flag2", version: 1, deleted: true } + + core.force_set(THINGS, item1) + core.force_set(THINGS, item2) + expect(wrapper.all(THINGS)).to eq({ item1[:key] => item1 }) + end + + it "upserts item successfully" do + core = MockCore.new + wrapper = subject.new(core, opts) + key = "flag" + itemv1 = { key: key, version: 1 } + itemv2 = { key: key, version: 2 } + + wrapper.upsert(THINGS, itemv1) + expect(core.data[THINGS][key]).to eq itemv1 + + wrapper.upsert(THINGS, itemv2) + expect(core.data[THINGS][key]).to eq itemv2 + + # if we have a cache, verify that the new item is now cached by writing a different value + # to the underlying data - Get should still return the cached item + if cached + itemv3 = { key: key, version: 3 } + core.force_set(THINGS, itemv3) + end + + expect(wrapper.get(THINGS, key)).to eq itemv2 + end + + it "deletes item" do + core = MockCore.new + wrapper = subject.new(core, opts) + key = "flag" + itemv1 = { key: key, version: 1 } + itemv2 = { key: key, version: 2, deleted: true } + itemv3 = { key: key, version: 3 } + + core.force_set(THINGS, itemv1) + expect(wrapper.get(THINGS, key)).to eq itemv1 + + wrapper.delete(THINGS, key, 2) + expect(core.data[THINGS][key]).to eq itemv2 + + core.force_set(THINGS, itemv3) # make a change that bypasses the cache + + expect(wrapper.get(THINGS, key)).to eq(cached ? nil : itemv3) + end + end + + context "cached" do + include_examples "tests", true + + cached_opts = { expiration: 30 } + + it "get uses values from init" do + core = MockCore.new + wrapper = subject.new(core, cached_opts) + item1 = { key: "flag1", version: 1 } + item2 = { key: "flag2", version: 1 } + + wrapper.init({ THINGS => { item1[:key] => item1, item2[:key] => item2 } }) + core.force_remove(THINGS, item1[:key]) + + expect(wrapper.get(THINGS, item1[:key])).to eq item1 + end + + it "get all uses values from init" do + core = MockCore.new + wrapper = subject.new(core, cached_opts) + item1 = { key: "flag1", version: 1 } + item2 = { key: "flag2", version: 1 } + + wrapper.init({ THINGS => { item1[:key] => item1, item2[:key] => item2 } }) + core.force_remove(THINGS, item1[:key]) + + expect(wrapper.all(THINGS)).to eq({ item1[:key] => item1, item2[:key] => item2 }) + end + + it "upsert doesn't update cache if unsuccessful" do + # This is for an upsert where the data in the store has a higher version. In an uncached + # store, this is just a no-op as far as the wrapper is concerned so there's nothing to + # test here. In a cached store, we need to verify that the cache has been refreshed + # using the data that was found in the store. + core = MockCore.new + wrapper = subject.new(core, cached_opts) + key = "flag" + itemv1 = { key: key, version: 1 } + itemv2 = { key: key, version: 2 } + + wrapper.upsert(THINGS, itemv2) + expect(core.data[THINGS][key]).to eq itemv2 + + wrapper.upsert(THINGS, itemv1) + expect(core.data[THINGS][key]).to eq itemv2 # value in store remains the same + + itemv3 = { key: key, version: 3 } + core.force_set(THINGS, itemv3) # bypasses cache so we can verify that itemv2 is in the cache + expect(wrapper.get(THINGS, key)).to eq itemv2 + end + + it "initialized? can cache false result" do + core = MockCore.new + wrapper = subject.new(core, { expiration: 0.2 }) # use a shorter cache TTL for this test + + expect(wrapper.initialized?).to eq false + expect(core.inited_query_count).to eq 1 + + core.inited = true + expect(wrapper.initialized?).to eq false + expect(core.inited_query_count).to eq 1 + + sleep(0.5) + + expect(wrapper.initialized?).to eq true + expect(core.inited_query_count).to eq 2 + + # From this point on it should remain true and the method should not be called + expect(wrapper.initialized?).to eq true + expect(core.inited_query_count).to eq 2 + end + end + + context "uncached" do + include_examples "tests", false + + uncached_opts = { expiration: 0 } + + it "queries internal initialized state only if not already inited" do + core = MockCore.new + wrapper = subject.new(core, uncached_opts) + + expect(wrapper.initialized?).to eq false + expect(core.inited_query_count).to eq 1 + + core.inited = true + expect(wrapper.initialized?).to eq true + expect(core.inited_query_count).to eq 2 + + core.inited = false + expect(wrapper.initialized?).to eq true + expect(core.inited_query_count).to eq 2 + end + + it "does not query internal initialized state if init has been called" do + core = MockCore.new + wrapper = subject.new(core, uncached_opts) + + expect(wrapper.initialized?).to eq false + expect(core.inited_query_count).to eq 1 + + wrapper.init({}) + + expect(wrapper.initialized?).to eq true + expect(core.inited_query_count).to eq 1 + end + end + + class MockCore + def initialize + @data = {} + @inited = false + @inited_query_count = 0 + end + + attr_reader :data + attr_reader :inited_query_count + attr_accessor :inited + + def force_set(kind, item) + @data[kind] = {} unless @data.has_key?(kind) + @data[kind][item[:key]] = item + end + + def force_remove(kind, key) + @data[kind].delete(key) if @data.has_key?(kind) + end + + def init_internal(all_data) + @data = all_data + @inited = true + end + + def get_internal(kind, key) + items = @data[kind] + items.nil? ? nil : items[key] + end + + def get_all_internal(kind) + @data[kind] + end + + def upsert_internal(kind, item) + @data[kind] = {} unless @data.has_key?(kind) + old_item = @data[kind][item[:key]] + return old_item if !old_item.nil? && old_item[:version] >= item[:version] + @data[kind][item[:key]] = item + item + end + + def initialized_internal? + @inited_query_count = @inited_query_count + 1 + @inited + end + end + end + end +end diff --git a/spec/ldclient_end_to_end_spec.rb b/spec/ldclient_end_to_end_spec.rb index 9f9de608..449ba491 100644 --- a/spec/ldclient_end_to_end_spec.rb +++ b/spec/ldclient_end_to_end_spec.rb @@ -14,7 +14,7 @@ module LaunchDarkly # Note that we can't do end-to-end tests in streaming mode until we have a test server that can do streaming # responses, which is difficult in WEBrick. - describe "LDClient end-to-end" do + describe "LDClient end-to-end", flaky: true do it "starts in polling mode" do with_server do |poll_server| poll_server.setup_ok_response("/sdk/latest-all", DATA_WITH_ALWAYS_TRUE_FLAG.to_json, "application/json") diff --git a/spec/ldclient_evaluation_spec.rb b/spec/ldclient_evaluation_spec.rb index 424276fc..144b63b6 100644 --- a/spec/ldclient_evaluation_spec.rb +++ b/spec/ldclient_evaluation_spec.rb @@ -76,9 +76,6 @@ module LaunchDarkly end context "variation_detail" do - feature_with_value = { key: "key", on: false, offVariation: 0, variations: ["value"], version: 100, - trackEvents: true, debugEventsUntilDate: 1000 } - it "returns the default value if the client is offline" do with_client(test_config(offline: true)) do |offline_client| result = offline_client.variation_detail("doesntmatter", basic_context, "default") @@ -146,7 +143,7 @@ module LaunchDarkly context "all_flags_state" do let(:flag1) { { key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false } } - let(:flag2) { { key: "key2", version: 200, offVariation: 1, variations: [ 'x', 'value2' ], trackEvents: true, debugEventsUntilDate: 1000 } } + let(:flag2) { { key: "key2", version: 200, offVariation: 1, variations: %w[x value2], trackEvents: true, debugEventsUntilDate: 1000 } } let(:test_data) { td = Integrations::TestData.data_source td.use_preconfigured_flag(flag1) @@ -204,8 +201,8 @@ module LaunchDarkly future_time = (Time.now.to_f * 1000).to_i + 100000 td = Integrations::TestData.data_source td.use_preconfigured_flag({ key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false }) - td.use_preconfigured_flag({ key: "key2", version: 200, offVariation: 1, variations: [ 'x', 'value2' ], trackEvents: true }) - td.use_preconfigured_flag({ key: "key3", version: 300, offVariation: 1, variations: [ 'x', 'value3' ], debugEventsUntilDate: future_time }) + td.use_preconfigured_flag({ key: "key2", version: 200, offVariation: 1, variations: %w[x value2], trackEvents: true }) + td.use_preconfigured_flag({ key: "key3", version: 300, offVariation: 1, variations: %w[x value3], debugEventsUntilDate: future_time }) with_client(test_config(data_source: td)) do |client| state = client.all_flags_state({ key: 'userkey' }, { details_only_for_tracked_flags: true }) diff --git a/spec/ldclient_events_spec.rb b/spec/ldclient_events_spec.rb index 62adda39..e02981c2 100644 --- a/spec/ldclient_events_spec.rb +++ b/spec/ldclient_events_spec.rb @@ -45,7 +45,7 @@ def event_processor(client) td = Integrations::TestData.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) - logger = double().as_null_object + logger = double.as_null_object with_client(test_config(data_source: td, logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_eval_event) @@ -58,7 +58,7 @@ def event_processor(client) td = Integrations::TestData.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) - logger = double().as_null_object + logger = double.as_null_object keyless_user = { key: nil } with_client(test_config(data_source: td, logger: logger)) do |client| @@ -136,7 +136,7 @@ def event_processor(client) td = Integrations::TestData.data_source td.update(td.flag("flagkey").variations("value").on(false).off_variation(0)) - logger = double().as_null_object + logger = double.as_null_object with_client(test_config(data_source: td, logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_eval_event) @@ -149,7 +149,7 @@ def event_processor(client) td = Integrations::TestData.data_source td.update(td.flag("flagkey").variations("value").on(false).off_variation(0)) - logger = double().as_null_object + logger = double.as_null_object with_client(test_config(data_source: td, logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_eval_event) @@ -169,7 +169,7 @@ def event_processor(client) end it "does not send event, and logs warning, if context is nil" do - logger = double().as_null_object + logger = double.as_null_object with_client(test_config(logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_identify_event) @@ -179,7 +179,7 @@ def event_processor(client) end it "does not send event, and logs warning, if context key is blank" do - logger = double().as_null_object + logger = double.as_null_object with_client(test_config(logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_identify_event) @@ -211,7 +211,7 @@ def event_processor(client) end it "does not send event, and logs a warning, if context is nil" do - logger = double().as_null_object + logger = double.as_null_object with_client(test_config(logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_custom_event) @@ -221,7 +221,7 @@ def event_processor(client) end it "does not send event, and logs warning, if context key is nil" do - logger = double().as_null_object + logger = double.as_null_object with_client(test_config(logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_custom_event) diff --git a/spec/ldclient_listeners_spec.rb b/spec/ldclient_listeners_spec.rb index 8628f75b..2542b81e 100644 --- a/spec/ldclient_listeners_spec.rb +++ b/spec/ldclient_listeners_spec.rb @@ -30,7 +30,7 @@ module LaunchDarkly store.setup_metadata_error(StandardError.new("sorry")) - status2 = statuses.pop() + status2 = statuses.pop expect(status2.available).to be(false) expect(status2.stale).to be(false) diff --git a/spec/ldclient_spec.rb b/spec/ldclient_spec.rb index ad56b800..ce993b41 100644 --- a/spec/ldclient_spec.rb +++ b/spec/ldclient_spec.rb @@ -74,10 +74,10 @@ module LaunchDarkly it "passes data set to feature store in correct order on init" do store = CapturingFeatureStore.new td = Integrations::TestData.data_source - dependency_ordering_test_data[FEATURES].each { |key, flag| td.use_preconfigured_flag(flag) } - dependency_ordering_test_data[SEGMENTS].each { |key, segment| td.use_preconfigured_segment(segment) } + dependency_ordering_test_data[FEATURES].each { |_, flag| td.use_preconfigured_flag(flag) } + dependency_ordering_test_data[SEGMENTS].each { |_, segment| td.use_preconfigured_segment(segment) } - with_client(test_config(feature_store: store, data_source: td)) do |client| + with_client(test_config(feature_store: store, data_source: td)) do |_| data = store.received_data expect(data).not_to be_nil expect(data.count).to eq(2) @@ -105,4 +105,4 @@ module LaunchDarkly end end end -end \ No newline at end of file +end diff --git a/spec/mock_components.rb b/spec/mock_components.rb index 38b1afcb..e0d2572e 100644 --- a/spec/mock_components.rb +++ b/spec/mock_components.rb @@ -13,7 +13,7 @@ def null_data end def null_logger - double().as_null_object + double.as_null_object end def base_config diff --git a/spec/polling_spec.rb b/spec/polling_spec.rb index 8e5c1d48..2578dfe2 100644 --- a/spec/polling_spec.rb +++ b/spec/polling_spec.rb @@ -3,173 +3,175 @@ require 'ostruct' require "spec_helper" -describe LaunchDarkly::PollingProcessor do - subject { LaunchDarkly::PollingProcessor } - let(:executor) { SynchronousExecutor.new } - let(:status_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(executor, $null_log) } - let(:flag_change_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(executor, $null_log) } - let(:requestor) { double() } - - def with_processor(store, initialize_to_valid = false) - config = LaunchDarkly::Config.new(feature_store: store, logger: $null_log) - config.data_source_update_sink = LaunchDarkly::Impl::DataSource::UpdateSink.new(store, status_broadcaster, flag_change_broadcaster) - - if initialize_to_valid - # If the update sink receives an interrupted signal when the state is - # still initializing, it will continue staying in the initializing phase. - # Therefore, we set the state to valid before this test so we can - # determine if the interrupted signal is actually generated. - config.data_source_update_sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - end - - processor = subject.new(config, requestor) - begin - yield processor - ensure - processor.stop - end - end - - describe 'successful request' do - flag = LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flagkey', version: 1 }) - segment = LaunchDarkly::Impl::Model::Segment.new({ key: 'segkey', version: 1 }) - all_data = { - LaunchDarkly::FEATURES => { - flagkey: flag, - }, - LaunchDarkly::SEGMENTS => { - segkey: segment, - }, - } - - it 'puts feature data in store' do - allow(requestor).to receive(:request_all_data).and_return(all_data) - store = LaunchDarkly::InMemoryFeatureStore.new - with_processor(store) do |processor| - ready = processor.start - ready.wait - expect(store.get(LaunchDarkly::FEATURES, "flagkey")).to eq(flag) - expect(store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(segment) +module LaunchDarkly + describe PollingProcessor do + subject { PollingProcessor } + let(:executor) { SynchronousExecutor.new } + let(:status_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } + let(:flag_change_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } + let(:requestor) { double } + + def with_processor(store, initialize_to_valid = false) + config = Config.new(feature_store: store, logger: $null_log) + config.data_source_update_sink = Impl::DataSource::UpdateSink.new(store, status_broadcaster, flag_change_broadcaster) + + if initialize_to_valid + # If the update sink receives an interrupted signal when the state is + # still initializing, it will continue staying in the initializing phase. + # Therefore, we set the state to valid before this test so we can + # determine if the interrupted signal is actually generated. + config.data_source_update_sink.update_status(Interfaces::DataSource::Status::VALID, nil) end - end - it 'sets initialized to true' do - allow(requestor).to receive(:request_all_data).and_return(all_data) - store = LaunchDarkly::InMemoryFeatureStore.new - with_processor(store) do |processor| - ready = processor.start - ready.wait - expect(processor.initialized?).to be true - expect(store.initialized?).to be true + processor = subject.new(config, requestor) + begin + yield processor + ensure + processor.stop end end - it 'status is set to valid when data is received' do - allow(requestor).to receive(:request_all_data).and_return(all_data) - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) + describe 'successful request' do + flag = Impl::Model::FeatureFlag.new({ key: 'flagkey', version: 1 }) + segment = Impl::Model::Segment.new({ key: 'segkey', version: 1 }) + all_data = { + FEATURES => { + flagkey: flag, + }, + SEGMENTS => { + segkey: segment, + }, + } + + it 'puts feature data in store' do + allow(requestor).to receive(:request_all_data).and_return(all_data) + store = InMemoryFeatureStore.new + with_processor(store) do |processor| + ready = processor.start + ready.wait + expect(store.get(FEATURES, "flagkey")).to eq(flag) + expect(store.get(SEGMENTS, "segkey")).to eq(segment) + end + end - store = LaunchDarkly::InMemoryFeatureStore.new - with_processor(store) do |processor| - ready = processor.start - ready.wait - expect(store.get(LaunchDarkly::FEATURES, "flagkey")).to eq(flag) - expect(store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(segment) + it 'sets initialized to true' do + allow(requestor).to receive(:request_all_data).and_return(all_data) + store = InMemoryFeatureStore.new + with_processor(store) do |processor| + ready = processor.start + ready.wait + expect(processor.initialized?).to be true + expect(store.initialized?).to be true + end + end - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + it 'status is set to valid when data is received' do + allow(requestor).to receive(:request_all_data).and_return(all_data) + listener = ListenerSpy.new + status_broadcaster.add_listener(listener) + + store = InMemoryFeatureStore.new + with_processor(store) do |processor| + ready = processor.start + ready.wait + expect(store.get(FEATURES, "flagkey")).to eq(flag) + expect(store.get(SEGMENTS, "segkey")).to eq(segment) + + expect(listener.statuses.count).to eq(1) + expect(listener.statuses[0].state).to eq(Interfaces::DataSource::Status::VALID) + end end end - end - describe 'connection error' do - it 'does not cause immediate failure, does not set initialized' do - allow(requestor).to receive(:request_all_data).and_raise(StandardError.new("test error")) - store = LaunchDarkly::InMemoryFeatureStore.new - with_processor(store) do |processor| - ready = processor.start - finished = ready.wait(1) - expect(finished).to be false - expect(processor.initialized?).to be false - expect(store.initialized?).to be false + describe 'connection error' do + it 'does not cause immediate failure, does not set initialized' do + allow(requestor).to receive(:request_all_data).and_raise(StandardError.new("test error")) + store = InMemoryFeatureStore.new + with_processor(store) do |processor| + ready = processor.start + finished = ready.wait(1) + expect(finished).to be false + expect(processor.initialized?).to be false + expect(store.initialized?).to be false + end end end - end - describe 'HTTP errors' do - def verify_unrecoverable_http_error(status) - allow(requestor).to receive(:request_all_data).and_raise(LaunchDarkly::UnexpectedResponseError.new(status)) - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) + describe 'HTTP errors' do + def verify_unrecoverable_http_error(status) + allow(requestor).to receive(:request_all_data).and_raise(UnexpectedResponseError.new(status)) + listener = ListenerSpy.new + status_broadcaster.add_listener(listener) - with_processor(LaunchDarkly::InMemoryFeatureStore.new) do |processor| - ready = processor.start - finished = ready.wait(1) - expect(finished).to be true - expect(processor.initialized?).to be false + with_processor(InMemoryFeatureStore.new) do |processor| + ready = processor.start + finished = ready.wait(1) + expect(finished).to be true + expect(processor.initialized?).to be false - expect(listener.statuses.count).to eq(1) + expect(listener.statuses.count).to eq(1) - s = listener.statuses[0] - expect(s.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) - expect(s.last_error.status_code).to eq(status) + s = listener.statuses[0] + expect(s.state).to eq(Interfaces::DataSource::Status::OFF) + expect(s.last_error.status_code).to eq(status) + end end - end - def verify_recoverable_http_error(status) - allow(requestor).to receive(:request_all_data).and_raise(LaunchDarkly::UnexpectedResponseError.new(status)) - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) + def verify_recoverable_http_error(status) + allow(requestor).to receive(:request_all_data).and_raise(UnexpectedResponseError.new(status)) + listener = ListenerSpy.new + status_broadcaster.add_listener(listener) - with_processor(LaunchDarkly::InMemoryFeatureStore.new, true) do |processor| - ready = processor.start - finished = ready.wait(1) - expect(finished).to be false - expect(processor.initialized?).to be false + with_processor(InMemoryFeatureStore.new, true) do |processor| + ready = processor.start + finished = ready.wait(1) + expect(finished).to be false + expect(processor.initialized?).to be false - expect(listener.statuses.count).to eq(2) + expect(listener.statuses.count).to eq(2) - s = listener.statuses[1] - expect(s.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) - expect(s.last_error.status_code).to eq(status) + s = listener.statuses[1] + expect(s.state).to eq(Interfaces::DataSource::Status::INTERRUPTED) + expect(s.last_error.status_code).to eq(status) + end end - end - it 'stops immediately for error 401' do - verify_unrecoverable_http_error(401) - end + it 'stops immediately for error 401' do + verify_unrecoverable_http_error(401) + end - it 'stops immediately for error 403' do - verify_unrecoverable_http_error(403) - end + it 'stops immediately for error 403' do + verify_unrecoverable_http_error(403) + end - it 'does not stop immediately for error 408' do - verify_recoverable_http_error(408) - end + it 'does not stop immediately for error 408' do + verify_recoverable_http_error(408) + end - it 'does not stop immediately for error 429' do - verify_recoverable_http_error(429) - end + it 'does not stop immediately for error 429' do + verify_recoverable_http_error(429) + end - it 'does not stop immediately for error 503' do - verify_recoverable_http_error(503) + it 'does not stop immediately for error 503' do + verify_recoverable_http_error(503) + end end - end - - describe 'stop' do - it 'stops promptly rather than continuing to wait for poll interval' do - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - with_processor(LaunchDarkly::InMemoryFeatureStore.new) do |processor| - sleep(1) # somewhat arbitrary, but should ensure that it has started polling - start_time = Time.now - processor.stop - end_time = Time.now - expect(end_time - start_time).to be <(LaunchDarkly::Config.default_poll_interval - 5) - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + describe 'stop' do + it 'stops promptly rather than continuing to wait for poll interval' do + listener = ListenerSpy.new + status_broadcaster.add_listener(listener) + + with_processor(InMemoryFeatureStore.new) do |processor| + sleep(1) # somewhat arbitrary, but should ensure that it has started polling + start_time = Time.now + processor.stop + end_time = Time.now + expect(end_time - start_time).to be <(Config.default_poll_interval - 5) + + expect(listener.statuses.count).to eq(1) + expect(listener.statuses[0].state).to eq(Interfaces::DataSource::Status::OFF) + end end end end diff --git a/spec/reference_spec.rb b/spec/reference_spec.rb index 38b5e403..e1625694 100644 --- a/spec/reference_spec.rb +++ b/spec/reference_spec.rb @@ -1,7 +1,8 @@ require "ldclient-rb/reference" -describe LaunchDarkly::Reference do - subject { LaunchDarkly::Reference } +module LaunchDarkly +describe Reference do + subject { Reference } it "determines invalid formats" do [ @@ -92,10 +93,10 @@ describe "creating literal references" do it "can create valid references" do [ - ["name", "name"], - ["a/b", "a/b"], - ["/a/b~c", "/~1a~1b~0c"], - ["/", "/~1"], + %w[name name], + %w[a/b a/b], + %w[/a/b~c /~1a~1b~0c], + %w[/ /~1], ].each do |(literal, path)| expect(subject.create_literal(literal).raw_path).to eq(subject.create(path).raw_path) end @@ -108,3 +109,4 @@ end end end +end diff --git a/spec/requestor_spec.rb b/spec/requestor_spec.rb index 7fea7733..f2817d9e 100644 --- a/spec/requestor_spec.rb +++ b/spec/requestor_spec.rb @@ -2,213 +2,215 @@ require "model_builders" require "spec_helper" -$sdk_key = "secret" - -describe LaunchDarkly::Requestor do - def with_requestor(base_uri, opts = {}) - r = LaunchDarkly::Requestor.new($sdk_key, LaunchDarkly::Config.new({ base_uri: base_uri, application: {id: "id", version: "version"} }.merge(opts))) - begin - yield r - ensure - r.stop - end - end - - describe "request_all_flags" do - it "uses expected URI and headers" do - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_ok_response("/", "{}") - requestor.request_all_data() - expect(server.requests.count).to eq 1 - expect(server.requests[0].unparsed_uri).to eq "/sdk/latest-all" - expect(server.requests[0].header).to include({ - "authorization" => [ $sdk_key ], - "user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ], - "x-launchdarkly-tags" => [ "application-id/id application-version/version" ], - }) - end +module LaunchDarkly + describe Requestor do + let(:sdk_key) { "secret" } + + def with_requestor(base_uri, opts = {}) + r = Requestor.new(sdk_key, Config.new({ base_uri: base_uri, application: {id: "id", version: "version"} }.merge(opts))) + begin + yield r + ensure + r.stop end end - it "parses response" do - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_ok_response("/", expected_data.to_json) - data = requestor.request_all_data() - expect(data).to eq expected_data.to_store_data + describe "request_all_flags", flaky: true do + it "uses expected URI and headers" do + with_server do |server| + with_requestor(server.base_uri.to_s) do |requestor| + server.setup_ok_response("/", "{}") + requestor.request_all_data + expect(server.requests.count).to eq 1 + expect(server.requests[0].unparsed_uri).to eq "/sdk/latest-all" + expect(server.requests[0].header).to include({ + "authorization" => [ sdk_key ], + "user-agent" => [ "RubyClient/" + VERSION ], + "x-launchdarkly-tags" => [ "application-id/id application-version/version" ], + }) + end end end - end - it "logs debug output" do - logger = ::Logger.new($stdout) - logger.level = ::Logger::DEBUG - with_server do |server| - with_requestor(server.base_uri.to_s, { logger: logger }) do |requestor| - server.setup_ok_response("/", FlagBuilder.new("x").build.to_json) - expect do - requestor.request_all_data() - end.to output(/\[LDClient\] Got response from uri:/).to_stdout_from_any_process + it "parses response" do + expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) + with_server do |server| + with_requestor(server.base_uri.to_s) do |requestor| + server.setup_ok_response("/", expected_data.to_json) + data = requestor.request_all_data + expect(data).to eq expected_data.to_store_data + end end end - end - it "sends etag from previous response" do - etag = "xyz" - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_response("/") do |req, res| - res.status = 200 - res.body = "{}" - res["ETag"] = etag + it "logs debug output" do + logger = ::Logger.new($stdout) + logger.level = ::Logger::DEBUG + with_server do |server| + with_requestor(server.base_uri.to_s, { logger: logger }) do |requestor| + server.setup_ok_response("/", FlagBuilder.new("x").build.to_json) + expect do + requestor.request_all_data + end.to output(/\[LDClient\] Got response from uri:/).to_stdout_from_any_process end - requestor.request_all_data() - expect(server.requests.count).to eq 1 - - requestor.request_all_data() - expect(server.requests.count).to eq 2 - expect(server.requests[1].header).to include({ "if-none-match" => [ etag ] }) end end - end - it "sends wrapper header if configured" do - with_server do |server| - with_requestor(server.base_uri.to_s, { wrapper_name: 'MyWrapper', wrapper_version: '1.0' }) do |requestor| - server.setup_ok_response("/", "{}") - requestor.request_all_data() - expect(server.requests.count).to eq 1 - expect(server.requests[0].header).to include({ - "x-launchdarkly-wrapper" => [ "MyWrapper/1.0" ], - }) + it "sends etag from previous response" do + etag = "xyz" + with_server do |server| + with_requestor(server.base_uri.to_s) do |requestor| + server.setup_response("/") do |_, res| + res.status = 200 + res.body = "{}" + res["ETag"] = etag + end + requestor.request_all_data + expect(server.requests.count).to eq 1 + + requestor.request_all_data + expect(server.requests.count).to eq 2 + expect(server.requests[1].header).to include({ "if-none-match" => [ etag ] }) + end end end - end - it "can reuse cached data" do - etag = "xyz" - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_response("/") do |req, res| - res.status = 200 - res.body = expected_data.to_json - res["ETag"] = etag + it "sends wrapper header if configured" do + with_server do |server| + with_requestor(server.base_uri.to_s, { wrapper_name: 'MyWrapper', wrapper_version: '1.0' }) do |requestor| + server.setup_ok_response("/", "{}") + requestor.request_all_data + expect(server.requests.count).to eq 1 + expect(server.requests[0].header).to include({ + "x-launchdarkly-wrapper" => [ "MyWrapper/1.0" ], + }) end - requestor.request_all_data() - expect(server.requests.count).to eq 1 + end + end - server.setup_response("/") do |req, res| - res.status = 304 + it "can reuse cached data" do + etag = "xyz" + expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) + with_server do |server| + with_requestor(server.base_uri.to_s) do |requestor| + server.setup_response("/") do |_, res| + res.status = 200 + res.body = expected_data.to_json + res["ETag"] = etag + end + requestor.request_all_data + expect(server.requests.count).to eq 1 + + server.setup_response("/") do |_, res| + res.status = 304 + end + data = requestor.request_all_data + expect(server.requests.count).to eq 2 + expect(server.requests[1].header).to include({ "if-none-match" => [ etag ] }) + expect(data).to eq expected_data.to_store_data end - data = requestor.request_all_data() - expect(server.requests.count).to eq 2 - expect(server.requests[1].header).to include({ "if-none-match" => [ etag ] }) - expect(data).to eq expected_data.to_store_data end end - end - it "replaces cached data with new data" do - etag1 = "abc" - etag2 = "xyz" - expected_data1 = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - expected_data2 = DataSetBuilder.new.flag(FlagBuilder.new("y").build) - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_response("/") do |req, res| - res.status = 200 - res.body = expected_data1.to_json - res["ETag"] = etag1 - end - data = requestor.request_all_data() - expect(data).to eq expected_data1.to_store_data - expect(server.requests.count).to eq 1 + it "replaces cached data with new data" do + etag1 = "abc" + etag2 = "xyz" + expected_data1 = DataSetBuilder.new.flag(FlagBuilder.new("x").build) + expected_data2 = DataSetBuilder.new.flag(FlagBuilder.new("y").build) + with_server do |server| + with_requestor(server.base_uri.to_s) do |requestor| + server.setup_response("/") do |_, res| + res.status = 200 + res.body = expected_data1.to_json + res["ETag"] = etag1 + end + data = requestor.request_all_data + expect(data).to eq expected_data1.to_store_data + expect(server.requests.count).to eq 1 - server.setup_response("/") do |req, res| - res.status = 304 - end - data = requestor.request_all_data() - expect(data).to eq expected_data1.to_store_data - expect(server.requests.count).to eq 2 - expect(server.requests[1].header).to include({ "if-none-match" => [ etag1 ] }) - - server.setup_response("/") do |req, res| - res.status = 200 - res.body = expected_data2.to_json - res["ETag"] = etag2 - end - data = requestor.request_all_data() - expect(data).to eq expected_data2.to_store_data - expect(server.requests.count).to eq 3 - expect(server.requests[2].header).to include({ "if-none-match" => [ etag1 ] }) + server.setup_response("/") do |_, res| + res.status = 304 + end + data = requestor.request_all_data + expect(data).to eq expected_data1.to_store_data + expect(server.requests.count).to eq 2 + expect(server.requests[1].header).to include({ "if-none-match" => [ etag1 ] }) + + server.setup_response("/") do |_, res| + res.status = 200 + res.body = expected_data2.to_json + res["ETag"] = etag2 + end + data = requestor.request_all_data + expect(data).to eq expected_data2.to_store_data + expect(server.requests.count).to eq 3 + expect(server.requests[2].header).to include({ "if-none-match" => [ etag1 ] }) - server.setup_response("/") do |req, res| - res.status = 304 + server.setup_response("/") do |_, res| + res.status = 304 + end + data = requestor.request_all_data + expect(data).to eq expected_data2.to_store_data + expect(server.requests.count).to eq 4 + expect(server.requests[3].header).to include({ "if-none-match" => [ etag2 ] }) end - data = requestor.request_all_data() - expect(data).to eq expected_data2.to_store_data - expect(server.requests.count).to eq 4 - expect(server.requests[3].header).to include({ "if-none-match" => [ etag2 ] }) end end - end - it "uses UTF-8 encoding by default" do - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("flagkey").variations("blue", "grėeń").build) - with_server do |server| - server.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json") - with_requestor(server.base_uri.to_s) do |requestor| - data = requestor.request_all_data - expect(data).to eq expected_data.to_store_data + it "uses UTF-8 encoding by default" do + expected_data = DataSetBuilder.new.flag(FlagBuilder.new("flagkey").variations("blue", "grėeń").build) + with_server do |server| + server.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json") + with_requestor(server.base_uri.to_s) do |requestor| + data = requestor.request_all_data + expect(data).to eq expected_data.to_store_data + end end end - end - it "detects other encodings from Content-Type" do - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("flagkey").variations("proszę", "dziękuję").build) - with_server do |server| - server.setup_ok_response("/sdk/latest-all", expected_data.to_json.encode(Encoding::ISO_8859_2), - "text/plain; charset=ISO-8859-2") - with_requestor(server.base_uri.to_s) do |requestor| - data = requestor.request_all_data - expect(data).to eq expected_data.to_store_data + it "detects other encodings from Content-Type" do + expected_data = DataSetBuilder.new.flag(FlagBuilder.new("flagkey").variations("proszę", "dziękuję").build) + with_server do |server| + server.setup_ok_response("/sdk/latest-all", expected_data.to_json.encode(Encoding::ISO_8859_2), + "text/plain; charset=ISO-8859-2") + with_requestor(server.base_uri.to_s) do |requestor| + data = requestor.request_all_data + expect(data).to eq expected_data.to_store_data + end end end - end - it "throws exception for error status" do - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_response("/") do |req, res| - res.status = 400 + it "throws exception for error status" do + with_server do |server| + with_requestor(server.base_uri.to_s) do |requestor| + server.setup_response("/") do |_, res| + res.status = 400 + end + expect { requestor.request_all_data }.to raise_error(UnexpectedResponseError) end - expect { requestor.request_all_data() }.to raise_error(LaunchDarkly::UnexpectedResponseError) end end - end - it "can use a proxy server" do - fake_target_uri = "http://request-will-not-really-go-here" - # Instead of a real proxy server, we just create a basic test HTTP server that - # pretends to be a proxy. The proof that the proxy logic is working correctly is - # that the request goes to that server, instead of to fake_target_uri. We can't - # use a real proxy that really forwards requests to another test server, because - # that test server would be at localhost, and proxy environment variables are - # ignored if the target is localhost. - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - with_server do |proxy| - proxy.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json", { "etag" => "x" }) - begin - ENV["http_proxy"] = proxy.base_uri.to_s - with_requestor(fake_target_uri) do |requestor| - data = requestor.request_all_data - expect(data).to eq expected_data.to_store_data + it "can use a proxy server" do + fake_target_uri = "http://request-will-not-really-go-here" + # Instead of a real proxy server, we just create a basic test HTTP server that + # pretends to be a proxy. The proof that the proxy logic is working correctly is + # that the request goes to that server, instead of to fake_target_uri. We can't + # use a real proxy that really forwards requests to another test server, because + # that test server would be at localhost, and proxy environment variables are + # ignored if the target is localhost. + expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) + with_server do |proxy| + proxy.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json", { "etag" => "x" }) + begin + ENV["http_proxy"] = proxy.base_uri.to_s + with_requestor(fake_target_uri) do |requestor| + data = requestor.request_all_data + expect(data).to eq expected_data.to_store_data + end + ensure + ENV["http_proxy"] = nil end - ensure - ENV["http_proxy"] = nil end end end diff --git a/spec/segment_store_spec_base.rb b/spec/segment_store_spec_base.rb index c3ddf82a..c1630357 100644 --- a/spec/segment_store_spec_base.rb +++ b/spec/segment_store_spec_base.rb @@ -13,14 +13,14 @@ let(:key0) { segment0[:key].to_sym } let!(:store) do - s = create_store_method.call() + s = create_store_method.call s.init({ key0 => segment0 }) s end - def new_version_plus(f, deltaVersion, attrs = {}) + def new_version_plus(f, delta_version, attrs = {}) f1 = f.clone - f1[:version] = f[:version] + deltaVersion + f1[:version] = f[:version] + delta_version f1.update(attrs) f1 end @@ -48,7 +48,7 @@ def new_version_plus(f, deltaVersion, attrs = {}) feature1[:version] = 5 feature1[:on] = false store.upsert(:"test-feature-flag1", feature1) - expect(store.all).to eq ({ key0 => segment0, :"test-feature-flag1" => feature1 }) + expect(store.all).to eq({ key0 => segment0, :"test-feature-flag1" => feature1 }) end it "can add new feature" do diff --git a/spec/simple_lru_cache_spec.rb b/spec/simple_lru_cache_spec.rb index fabf8738..d7b507e0 100644 --- a/spec/simple_lru_cache_spec.rb +++ b/spec/simple_lru_cache_spec.rb @@ -1,24 +1,26 @@ require "spec_helper" -describe LaunchDarkly::SimpleLRUCacheSet do - subject { LaunchDarkly::SimpleLRUCacheSet } +module LaunchDarkly + describe SimpleLRUCacheSet do + subject { SimpleLRUCacheSet } - it "retains values up to capacity" do - lru = subject.new(3) - expect(lru.add("a")).to be false - expect(lru.add("b")).to be false - expect(lru.add("c")).to be false - expect(lru.add("a")).to be true - expect(lru.add("b")).to be true - expect(lru.add("c")).to be true - end + it "retains values up to capacity" do + lru = subject.new(3) + expect(lru.add("a")).to be false + expect(lru.add("b")).to be false + expect(lru.add("c")).to be false + expect(lru.add("a")).to be true + expect(lru.add("b")).to be true + expect(lru.add("c")).to be true + end - it "discards oldest value on overflow" do - lru = subject.new(2) - expect(lru.add("a")).to be false - expect(lru.add("b")).to be false - expect(lru.add("a")).to be true - expect(lru.add("c")).to be false # b is discarded as oldest - expect(lru.add("b")).to be false + it "discards oldest value on overflow" do + lru = subject.new(2) + expect(lru.add("a")).to be false + expect(lru.add("b")).to be false + expect(lru.add("a")).to be true + expect(lru.add("c")).to be false # b is discarded as oldest + expect(lru.add("b")).to be false + end end -end \ No newline at end of file +end diff --git a/spec/store_spec.rb b/spec/store_spec.rb index 6d499e9b..7a74231b 100644 --- a/spec/store_spec.rb +++ b/spec/store_spec.rb @@ -1,10 +1,12 @@ require "spec_helper" -describe LaunchDarkly::ThreadSafeMemoryStore do - subject { LaunchDarkly::ThreadSafeMemoryStore } - let(:store) { subject.new } - it "can read and write" do - store.write("key", "value") - expect(store.read("key")).to eq "value" +module LaunchDarkly + describe ThreadSafeMemoryStore do + subject { ThreadSafeMemoryStore } + let(:store) { subject.new } + it "can read and write" do + store.write("key", "value") + expect(store.read("key")).to eq "value" + end end end diff --git a/spec/stream_spec.rb b/spec/stream_spec.rb index 7016ee77..c08edc0b 100644 --- a/spec/stream_spec.rb +++ b/spec/stream_spec.rb @@ -2,66 +2,69 @@ require "model_builders" require "spec_helper" -describe LaunchDarkly::StreamProcessor do - subject { LaunchDarkly::StreamProcessor } - let(:executor) { SynchronousExecutor.new } - let(:status_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(executor, $null_log) } - let(:flag_change_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(executor, $null_log) } - let(:config) { - config = LaunchDarkly::Config.new - config.data_source_update_sink = LaunchDarkly::Impl::DataSource::UpdateSink.new(config.feature_store, status_broadcaster, flag_change_broadcaster) - config.data_source_update_sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - config - } - let(:processor) { subject.new("sdk_key", config) } +module LaunchDarkly + describe StreamProcessor do + subject { StreamProcessor } + let(:executor) { SynchronousExecutor.new } + let(:status_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } + let(:flag_change_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } + let(:config) { + config = Config.new + config.data_source_update_sink = Impl::DataSource::UpdateSink.new(config.feature_store, status_broadcaster, flag_change_broadcaster) + config.data_source_update_sink.update_status(Interfaces::DataSource::Status::VALID, nil) + config + } + let(:processor) { subject.new("sdk_key", config) } - describe '#process_message' do - let(:put_message) { SSE::StreamEvent.new(:put, '{"data":{"flags":{"asdf": {"key": "asdf"}},"segments":{"segkey": {"key": "segkey"}}}}') } - let(:patch_flag_message) { SSE::StreamEvent.new(:patch, '{"path": "/flags/key", "data": {"key": "asdf", "version": 1}}') } - let(:patch_seg_message) { SSE::StreamEvent.new(:patch, '{"path": "/segments/key", "data": {"key": "asdf", "version": 1}}') } - let(:delete_flag_message) { SSE::StreamEvent.new(:delete, '{"path": "/flags/key", "version": 2}') } - let(:delete_seg_message) { SSE::StreamEvent.new(:delete, '{"path": "/segments/key", "version": 2}') } - let(:invalid_message) { SSE::StreamEvent.new(:put, '{Hi there}') } + describe '#process_message' do + let(:put_message) { SSE::StreamEvent.new(:put, '{"data":{"flags":{"asdf": {"key": "asdf"}},"segments":{"segkey": {"key": "segkey"}}}}') } + let(:patch_flag_message) { SSE::StreamEvent.new(:patch, '{"path": "/flags/key", "data": {"key": "asdf", "version": 1}}') } + let(:patch_seg_message) { SSE::StreamEvent.new(:patch, '{"path": "/segments/key", "data": {"key": "asdf", "version": 1}}') } + let(:delete_flag_message) { SSE::StreamEvent.new(:delete, '{"path": "/flags/key", "version": 2}') } + let(:delete_seg_message) { SSE::StreamEvent.new(:delete, '{"path": "/segments/key", "version": 2}') } + let(:invalid_message) { SSE::StreamEvent.new(:put, '{Hi there}') } - it "will accept PUT methods" do - processor.send(:process_message, put_message) - expect(config.feature_store.get(LaunchDarkly::FEATURES, "asdf")).to eq(Flags.from_hash(key: "asdf")) - expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(Segments.from_hash(key: "segkey")) - end - it "will accept PATCH methods for flags" do - processor.send(:process_message, patch_flag_message) - expect(config.feature_store.get(LaunchDarkly::FEATURES, "asdf")).to eq(Flags.from_hash(key: "asdf", version: 1)) - end - it "will accept PATCH methods for segments" do - processor.send(:process_message, patch_seg_message) - expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "asdf")).to eq(Segments.from_hash(key: "asdf", version: 1)) - end - it "will accept DELETE methods for flags" do - processor.send(:process_message, patch_flag_message) - processor.send(:process_message, delete_flag_message) - expect(config.feature_store.get(LaunchDarkly::FEATURES, "key")).to eq(nil) - end - it "will accept DELETE methods for segments" do - processor.send(:process_message, patch_seg_message) - processor.send(:process_message, delete_seg_message) - expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "key")).to eq(nil) - end - it "will log a warning if the method is not recognized" do - expect(processor.instance_variable_get(:@config).logger).to receive :warn - processor.send(:process_message, SSE::StreamEvent.new(type: :get, data: "", id: nil)) - end - it "status listener will trigger error when JSON is invalid" do - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - begin - processor.send(:process_message, invalid_message) - rescue + it "will accept PUT methods" do + processor.send(:process_message, put_message) + expect(config.feature_store.get(FEATURES, "asdf")).to eq(Flags.from_hash(key: "asdf")) + expect(config.feature_store.get(SEGMENTS, "segkey")).to eq(Segments.from_hash(key: "segkey")) end + it "will accept PATCH methods for flags" do + processor.send(:process_message, patch_flag_message) + expect(config.feature_store.get(FEATURES, "asdf")).to eq(Flags.from_hash(key: "asdf", version: 1)) + end + it "will accept PATCH methods for segments" do + processor.send(:process_message, patch_seg_message) + expect(config.feature_store.get(SEGMENTS, "asdf")).to eq(Segments.from_hash(key: "asdf", version: 1)) + end + it "will accept DELETE methods for flags" do + processor.send(:process_message, patch_flag_message) + processor.send(:process_message, delete_flag_message) + expect(config.feature_store.get(FEATURES, "key")).to eq(nil) + end + it "will accept DELETE methods for segments" do + processor.send(:process_message, patch_seg_message) + processor.send(:process_message, delete_seg_message) + expect(config.feature_store.get(SEGMENTS, "key")).to eq(nil) + end + it "will log a warning if the method is not recognized" do + expect(processor.instance_variable_get(:@config).logger).to receive :warn + processor.send(:process_message, SSE::StreamEvent.new(type: :get, data: "", id: nil)) + end + it "status listener will trigger error when JSON is invalid" do + listener = ListenerSpy.new + status_broadcaster.add_listener(listener) + + begin + processor.send(:process_message, invalid_message) + rescue + # Ignored + end - expect(listener.statuses.count).to eq(2) - expect(listener.statuses[1].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) - expect(listener.statuses[1].last_error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA) + expect(listener.statuses.count).to eq(2) + expect(listener.statuses[1].state).to eq(Interfaces::DataSource::Status::INTERRUPTED) + expect(listener.statuses[1].last_error.kind).to eq(Interfaces::DataSource::ErrorInfo::INVALID_DATA) + end end end end diff --git a/spec/util_spec.rb b/spec/util_spec.rb index 50a72f76..2219ab3c 100644 --- a/spec/util_spec.rb +++ b/spec/util_spec.rb @@ -1,16 +1,18 @@ require "spec_helper" -describe LaunchDarkly::Util do - describe 'log_exception' do - let(:logger) { double() } +module LaunchDarkly + describe Util do + describe 'log_exception' do + let(:logger) { double } - it "logs error data" do - expect(logger).to receive(:error) - expect(logger).to receive(:debug) - begin - raise StandardError.new 'asdf' - rescue StandardError => exn - LaunchDarkly::Util.log_exception(logger, "message", exn) + it "logs error data" do + expect(logger).to receive(:error) + expect(logger).to receive(:debug) + begin + raise StandardError.new 'asdf' + rescue StandardError => exn + Util.log_exception(logger, "message", exn) + end end end end diff --git a/spec/version_spec.rb b/spec/version_spec.rb index fa70a1ac..e220ec02 100644 --- a/spec/version_spec.rb +++ b/spec/version_spec.rb @@ -1,7 +1,9 @@ require "spec_helper" -describe LaunchDarkly do - it "has a version" do - expect(LaunchDarkly::VERSION).to be +module LaunchDarkly + describe LaunchDarkly do + it "has a version" do + expect(VERSION).to be + end end end