launchdarkly-server-sdk 5.8.1 → 6.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +28 -122
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +1 -1
  4. data/.github/ISSUE_TEMPLATE/config.yml +5 -0
  5. data/.gitignore +2 -1
  6. data/.ldrelease/build-docs.sh +18 -0
  7. data/.ldrelease/circleci/linux/execute.sh +18 -0
  8. data/.ldrelease/circleci/mac/execute.sh +18 -0
  9. data/.ldrelease/circleci/template/build.sh +29 -0
  10. data/.ldrelease/circleci/template/publish.sh +23 -0
  11. data/.ldrelease/circleci/template/set-gem-home.sh +7 -0
  12. data/.ldrelease/circleci/template/test.sh +10 -0
  13. data/.ldrelease/circleci/template/update-version.sh +8 -0
  14. data/.ldrelease/circleci/windows/execute.ps1 +19 -0
  15. data/.ldrelease/config.yml +14 -2
  16. data/CHANGELOG.md +29 -0
  17. data/CONTRIBUTING.md +1 -1
  18. data/README.md +4 -3
  19. data/azure-pipelines.yml +1 -1
  20. data/docs/Makefile +26 -0
  21. data/docs/index.md +9 -0
  22. data/launchdarkly-server-sdk.gemspec +16 -16
  23. data/lib/ldclient-rb.rb +0 -1
  24. data/lib/ldclient-rb/config.rb +15 -3
  25. data/lib/ldclient-rb/evaluation_detail.rb +324 -0
  26. data/lib/ldclient-rb/events.rb +6 -7
  27. data/lib/ldclient-rb/file_data_source.rb +1 -1
  28. data/lib/ldclient-rb/impl/evaluator.rb +231 -0
  29. data/lib/ldclient-rb/impl/evaluator_bucketing.rb +87 -0
  30. data/lib/ldclient-rb/impl/evaluator_operators.rb +160 -0
  31. data/lib/ldclient-rb/impl/event_factory.rb +28 -0
  32. data/lib/ldclient-rb/impl/event_sender.rb +56 -40
  33. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +5 -5
  34. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +5 -5
  35. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +5 -7
  36. data/lib/ldclient-rb/impl/model/serialization.rb +62 -0
  37. data/lib/ldclient-rb/impl/unbounded_pool.rb +34 -0
  38. data/lib/ldclient-rb/ldclient.rb +36 -15
  39. data/lib/ldclient-rb/polling.rb +1 -4
  40. data/lib/ldclient-rb/requestor.rb +25 -15
  41. data/lib/ldclient-rb/stream.rb +9 -6
  42. data/lib/ldclient-rb/util.rb +12 -8
  43. data/lib/ldclient-rb/version.rb +1 -1
  44. data/spec/evaluation_detail_spec.rb +135 -0
  45. data/spec/event_sender_spec.rb +20 -2
  46. data/spec/events_spec.rb +10 -0
  47. data/spec/http_util.rb +11 -1
  48. data/spec/impl/evaluator_bucketing_spec.rb +216 -0
  49. data/spec/impl/evaluator_clause_spec.rb +55 -0
  50. data/spec/impl/evaluator_operators_spec.rb +141 -0
  51. data/spec/impl/evaluator_rule_spec.rb +128 -0
  52. data/spec/impl/evaluator_segment_spec.rb +125 -0
  53. data/spec/impl/evaluator_spec.rb +349 -0
  54. data/spec/impl/evaluator_spec_base.rb +75 -0
  55. data/spec/impl/event_factory_spec.rb +108 -0
  56. data/spec/impl/model/serialization_spec.rb +41 -0
  57. data/spec/launchdarkly-server-sdk_spec.rb +1 -1
  58. data/spec/ldclient_end_to_end_spec.rb +34 -0
  59. data/spec/ldclient_spec.rb +64 -12
  60. data/spec/polling_spec.rb +2 -2
  61. data/spec/redis_feature_store_spec.rb +2 -2
  62. data/spec/requestor_spec.rb +11 -11
  63. metadata +92 -48
  64. data/.yardopts +0 -9
  65. data/Gemfile.lock +0 -89
  66. data/lib/ldclient-rb/evaluation.rb +0 -462
  67. data/scripts/gendocs.sh +0 -11
  68. data/scripts/release.sh +0 -27
  69. data/spec/evaluation_spec.rb +0 -789
@@ -37,10 +37,7 @@ module LaunchDarkly
37
37
  def poll
38
38
  all_data = @requestor.request_all_data
39
39
  if all_data
40
- @config.feature_store.init({
41
- FEATURES => all_data[:flags],
42
- SEGMENTS => all_data[:segments]
43
- })
40
+ @config.feature_store.init(all_data)
44
41
  if @initialized.make_true
45
42
  @config.logger.info { "[LDClient] Polling connection initialized" }
46
43
  @ready.set
@@ -1,6 +1,9 @@
1
+ require "ldclient-rb/impl/model/serialization"
2
+
1
3
  require "concurrent/atomics"
2
4
  require "json"
3
5
  require "uri"
6
+ require "http"
4
7
 
5
8
  module LaunchDarkly
6
9
  # @private
@@ -22,37 +25,44 @@ module LaunchDarkly
22
25
  def initialize(sdk_key, config)
23
26
  @sdk_key = sdk_key
24
27
  @config = config
25
- @client = Util.new_http_client(@config.base_uri, @config)
28
+ @http_client = LaunchDarkly::Util.new_http_client(config.base_uri, config)
26
29
  @cache = @config.cache_store
27
30
  end
28
31
 
29
32
  def request_all_data()
30
- make_request("/sdk/latest-all")
33
+ all_data = JSON.parse(make_request("/sdk/latest-all"), symbolize_names: true)
34
+ Impl::Model.make_all_store_data(all_data)
31
35
  end
32
36
 
33
37
  def stop
34
38
  begin
35
- @client.finish
39
+ @http_client.close
36
40
  rescue
37
41
  end
38
42
  end
39
43
 
40
44
  private
41
45
 
46
+ def request_single_item(kind, path)
47
+ Impl::Model.deserialize(kind, make_request(path))
48
+ end
49
+
42
50
  def make_request(path)
43
- @client.start if !@client.started?
44
51
  uri = URI(@config.base_uri + path)
45
- req = Net::HTTP::Get.new(uri)
46
- Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| req[k] = v }
47
- req["Connection"] = "keep-alive"
52
+ headers = {}
53
+ Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v }
54
+ headers["Connection"] = "keep-alive"
48
55
  cached = @cache.read(uri)
49
56
  if !cached.nil?
50
- req["If-None-Match"] = cached.etag
57
+ headers["If-None-Match"] = cached.etag
51
58
  end
52
- res = @client.request(req)
53
- status = res.code.to_i
54
- @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{res.to_hash}\n\tbody: #{res.body}" }
55
-
59
+ response = @http_client.request("GET", uri, {
60
+ headers: headers
61
+ })
62
+ status = response.status.code
63
+ @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{response.headers}\n\tbody: #{res.to_s}" }
64
+ # must fully read body for persistent connections
65
+ body = response.to_s
56
66
  if status == 304 && !cached.nil?
57
67
  body = cached.body
58
68
  else
@@ -60,11 +70,11 @@ module LaunchDarkly
60
70
  if status < 200 || status >= 300
61
71
  raise UnexpectedResponseError.new(status)
62
72
  end
63
- body = fix_encoding(res.body, res["content-type"])
64
- etag = res["etag"]
73
+ body = fix_encoding(body, response.headers["content-type"])
74
+ etag = response.headers["etag"]
65
75
  @cache.write(uri, CacheEntry.new(etag, body)) if !etag.nil?
66
76
  end
67
- JSON.parse(body, symbolize_names: true)
77
+ body
68
78
  end
69
79
 
70
80
  def fix_encoding(body, content_type)
@@ -1,3 +1,5 @@
1
+ require "ldclient-rb/impl/model/serialization"
2
+
1
3
  require "concurrent/atomics"
2
4
  require "json"
3
5
  require "ld-eventsource"
@@ -44,7 +46,8 @@ module LaunchDarkly
44
46
  opts = {
45
47
  headers: headers,
46
48
  read_timeout: READ_TIMEOUT_SECONDS,
47
- logger: @config.logger
49
+ logger: @config.logger,
50
+ socket_factory: @config.socket_factory
48
51
  }
49
52
  log_connection_started
50
53
  @es = SSE::Client.new(@config.stream_uri + "/all", **opts) do |conn|
@@ -82,10 +85,8 @@ module LaunchDarkly
82
85
  @config.logger.debug { "[LDClient] Stream received #{method} message: #{message.data}" }
83
86
  if method == PUT
84
87
  message = JSON.parse(message.data, symbolize_names: true)
85
- @feature_store.init({
86
- FEATURES => message[:data][:flags],
87
- SEGMENTS => message[:data][:segments]
88
- })
88
+ all_data = Impl::Model.make_all_store_data(message[:data])
89
+ @feature_store.init(all_data)
89
90
  @initialized.make_true
90
91
  @config.logger.info { "[LDClient] Stream initialized" }
91
92
  @ready.set
@@ -94,7 +95,9 @@ module LaunchDarkly
94
95
  for kind in [FEATURES, SEGMENTS]
95
96
  key = key_for_path(kind, data[:path])
96
97
  if key
97
- @feature_store.upsert(kind, data[:data])
98
+ data = data[:data]
99
+ Impl::Model.postprocess_item_after_deserializing!(kind, data)
100
+ @feature_store.upsert(kind, data)
98
101
  break
99
102
  end
100
103
  end
@@ -1,5 +1,5 @@
1
- require "net/http"
2
1
  require "uri"
2
+ require "http"
3
3
 
4
4
  module LaunchDarkly
5
5
  # @private
@@ -18,14 +18,18 @@ module LaunchDarkly
18
18
  end
19
19
  ret
20
20
  end
21
-
21
+
22
22
  def self.new_http_client(uri_s, config)
23
- uri = URI(uri_s)
24
- client = Net::HTTP.new(uri.hostname, uri.port)
25
- client.use_ssl = true if uri.scheme == "https"
26
- client.open_timeout = config.connect_timeout
27
- client.read_timeout = config.read_timeout
28
- client
23
+ http_client_options = {}
24
+ if config.socket_factory
25
+ http_client_options["socket_class"] = config.socket_factory
26
+ end
27
+ return HTTP::Client.new(http_client_options)
28
+ .timeout({
29
+ read: config.read_timeout,
30
+ connect: config.connect_timeout
31
+ })
32
+ .persistent(uri_s)
29
33
  end
30
34
 
31
35
  def self.log_exception(logger, message, exc)
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "5.8.1"
2
+ VERSION = "6.2.0"
3
3
  end
@@ -0,0 +1,135 @@
1
+ require "spec_helper"
2
+
3
+ module LaunchDarkly
4
+ describe "EvaluationDetail" do
5
+ subject { EvaluationDetail }
6
+
7
+ it "sets properties" do
8
+ expect(EvaluationDetail.new("x", 0, EvaluationReason::off).value).to eq "x"
9
+ expect(EvaluationDetail.new("x", 0, EvaluationReason::off).variation_index).to eq 0
10
+ expect(EvaluationDetail.new("x", 0, EvaluationReason::off).reason).to eq EvaluationReason::off
11
+ end
12
+
13
+ it "checks parameter types" do
14
+ expect { EvaluationDetail.new(nil, nil, EvaluationReason::off) }.not_to raise_error
15
+ expect { EvaluationDetail.new(nil, 0, EvaluationReason::off) }.not_to raise_error
16
+ expect { EvaluationDetail.new(nil, "x", EvaluationReason::off) }.to raise_error(ArgumentError)
17
+ expect { EvaluationDetail.new(nil, 0, { kind: "OFF" }) }.to raise_error(ArgumentError)
18
+ expect { EvaluationDetail.new(nil, 0, nil) }.to raise_error(ArgumentError)
19
+ end
20
+
21
+ it "equality test" do
22
+ expect(EvaluationDetail.new("x", 0, EvaluationReason::off)).to eq EvaluationDetail.new("x", 0, EvaluationReason::off)
23
+ expect(EvaluationDetail.new("x", 0, EvaluationReason::off)).not_to eq EvaluationDetail.new("y", 0, EvaluationReason::off)
24
+ expect(EvaluationDetail.new("x", 0, EvaluationReason::off)).not_to eq EvaluationDetail.new("x", 1, EvaluationReason::off)
25
+ expect(EvaluationDetail.new("x", 0, EvaluationReason::off)).not_to eq EvaluationDetail.new("x", 0, EvaluationReason::fallthrough)
26
+ end
27
+ end
28
+
29
+ describe "EvaluationReason" do
30
+ subject { EvaluationReason }
31
+
32
+ values = [
33
+ [ EvaluationReason::off, EvaluationReason::OFF, { "kind" => "OFF" }, "OFF", nil ],
34
+ [ EvaluationReason::fallthrough, EvaluationReason::FALLTHROUGH,
35
+ { "kind" => "FALLTHROUGH" }, "FALLTHROUGH", nil ],
36
+ [ EvaluationReason::target_match, EvaluationReason::TARGET_MATCH,
37
+ { "kind" => "TARGET_MATCH" }, "TARGET_MATCH", nil ],
38
+ [ EvaluationReason::rule_match(1, "x"), EvaluationReason::RULE_MATCH,
39
+ { "kind" => "RULE_MATCH", "ruleIndex" => 1, "ruleId" => "x" }, "RULE_MATCH(1,x)",
40
+ [ EvaluationReason::rule_match(2, "x"), EvaluationReason::rule_match(1, "y") ] ],
41
+ [ EvaluationReason::prerequisite_failed("x"), EvaluationReason::PREREQUISITE_FAILED,
42
+ { "kind" => "PREREQUISITE_FAILED", "prerequisiteKey" => "x" }, "PREREQUISITE_FAILED(x)" ],
43
+ [ EvaluationReason::error(EvaluationReason::ERROR_FLAG_NOT_FOUND), EvaluationReason::ERROR,
44
+ { "kind" => "ERROR", "errorKind" => "FLAG_NOT_FOUND" }, "ERROR(FLAG_NOT_FOUND)" ]
45
+ ]
46
+ values.each_index do |i|
47
+ params = values[i]
48
+ reason = params[0]
49
+ kind = params[1]
50
+ json_rep = params[2]
51
+ brief_str = params[3]
52
+ unequal_values = params[4]
53
+
54
+ describe "reason #{reason.kind}" do
55
+ it "has correct kind" do
56
+ expect(reason.kind).to eq kind
57
+ end
58
+
59
+ it "equality to self" do
60
+ expect(reason).to eq reason
61
+ end
62
+
63
+ it "inequality to others" do
64
+ values.each_index do |j|
65
+ if i != j
66
+ expect(reason).not_to eq values[j][0]
67
+ end
68
+ end
69
+ if !unequal_values.nil?
70
+ unequal_values.each do |v|
71
+ expect(reason).not_to eq v
72
+ end
73
+ end
74
+ end
75
+
76
+ it "JSON representation" do
77
+ expect(JSON.parse(reason.as_json.to_json)).to eq json_rep
78
+ expect(JSON.parse(reason.to_json)).to eq json_rep
79
+ end
80
+
81
+ it "brief representation" do
82
+ expect(reason.inspect).to eq brief_str
83
+ expect(reason.to_s).to eq brief_str
84
+ end
85
+ end
86
+ end
87
+
88
+ it "reuses singleton reasons" do
89
+ expect(EvaluationReason::off).to be EvaluationReason::off
90
+ expect(EvaluationReason::fallthrough).to be EvaluationReason::fallthrough
91
+ expect(EvaluationReason::target_match).to be EvaluationReason::target_match
92
+ expect(EvaluationReason::rule_match(1, 'x')).not_to be EvaluationReason::rule_match(1, 'x')
93
+ expect(EvaluationReason::prerequisite_failed('x')).not_to be EvaluationReason::prerequisite_failed('x')
94
+ errors = [ EvaluationReason::ERROR_CLIENT_NOT_READY, EvaluationReason::ERROR_FLAG_NOT_FOUND,
95
+ EvaluationReason::ERROR_MALFORMED_FLAG, EvaluationReason::ERROR_USER_NOT_SPECIFIED, EvaluationReason::ERROR_EXCEPTION ]
96
+ errors.each do |e|
97
+ expect(EvaluationReason::error(e)).to be EvaluationReason::error(e)
98
+ end
99
+ end
100
+
101
+ it "supports [] with JSON property names" do
102
+ expect(EvaluationReason::off[:kind]).to eq "OFF"
103
+ expect(EvaluationReason::off[:ruleIndex]).to be nil
104
+ expect(EvaluationReason::off[:ruleId]).to be nil
105
+ expect(EvaluationReason::off[:prerequisiteKey]).to be nil
106
+ expect(EvaluationReason::off[:errorKind]).to be nil
107
+ expect(EvaluationReason::rule_match(1, "x")[:ruleIndex]).to eq 1
108
+ expect(EvaluationReason::rule_match(1, "x")[:ruleId]).to eq "x"
109
+ expect(EvaluationReason::prerequisite_failed("x")[:prerequisiteKey]).to eq "x"
110
+ expect(EvaluationReason::error(EvaluationReason::ERROR_FLAG_NOT_FOUND)[:errorKind]).to eq "FLAG_NOT_FOUND"
111
+ end
112
+
113
+ it "freezes string properties" do
114
+ rm = EvaluationReason::rule_match(1, "x")
115
+ expect { rm.rule_id.upcase! }.to raise_error(RuntimeError)
116
+ pf = EvaluationReason::prerequisite_failed("x")
117
+ expect { pf.prerequisite_key.upcase! }.to raise_error(RuntimeError)
118
+ end
119
+
120
+ it "checks parameter types" do
121
+ expect { EvaluationReason::rule_match(nil, "x") }.to raise_error(ArgumentError)
122
+ expect { EvaluationReason::rule_match(true, "x") }.to raise_error(ArgumentError)
123
+ expect { EvaluationReason::rule_match(1, nil) }.not_to raise_error # we allow nil rule_id for backward compatibility
124
+ expect { EvaluationReason::rule_match(1, 9) }.to raise_error(ArgumentError)
125
+ expect { EvaluationReason::prerequisite_failed(nil) }.to raise_error(ArgumentError)
126
+ expect { EvaluationReason::prerequisite_failed(9) }.to raise_error(ArgumentError)
127
+ expect { EvaluationReason::error(nil) }.to raise_error(ArgumentError)
128
+ expect { EvaluationReason::error(9) }.to raise_error(ArgumentError)
129
+ end
130
+
131
+ it "does not allow direct access to constructor" do
132
+ expect { EvaluationReason.new(:off, nil, nil, nil, nil) }.to raise_error(NoMethodError)
133
+ end
134
+ end
135
+ end
@@ -39,12 +39,29 @@ module LaunchDarkly
39
39
  "authorization" => [ sdk_key ],
40
40
  "content-type" => [ "application/json" ],
41
41
  "user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ],
42
- "x-launchdarkly-event-schema" => [ "3" ]
42
+ "x-launchdarkly-event-schema" => [ "3" ],
43
+ "connection" => [ "Keep-Alive" ]
43
44
  })
44
45
  expect(req.header['x-launchdarkly-payload-id']).not_to eq []
45
46
  end
46
47
  end
47
-
48
+
49
+ it "can use a socket factory" do
50
+ with_server do |server|
51
+ server.setup_ok_response("/bulk", "")
52
+
53
+ config = Config.new(events_uri: "http://events.com/bulk", socket_factory: SocketFactoryFromHash.new({"events.com" => server.port}), logger: $null_log)
54
+ es = subject.new(sdk_key, config, nil, 0.1)
55
+
56
+ result = es.send_event_data(fake_data, "", false)
57
+
58
+ expect(result.success).to be true
59
+ req = server.await_request
60
+ expect(req.body).to eq fake_data
61
+ expect(req.host).to eq "events.com"
62
+ end
63
+ end
64
+
48
65
  it "generates a new payload ID for each payload" do
49
66
  with_sender_and_server do |es, server|
50
67
  server.setup_ok_response("/bulk", "")
@@ -78,6 +95,7 @@ module LaunchDarkly
78
95
  "authorization" => [ sdk_key ],
79
96
  "content-type" => [ "application/json" ],
80
97
  "user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ],
98
+ "connection" => [ "Keep-Alive" ]
81
99
  })
82
100
  expect(req.header['x-launchdarkly-event-schema']).to eq []
83
101
  expect(req.header['x-launchdarkly-payload-id']).to eq []
data/spec/events_spec.rb CHANGED
@@ -408,6 +408,16 @@ describe LaunchDarkly::EventProcessor do
408
408
  end
409
409
  end
410
410
 
411
+ it "queues alias event" do
412
+ with_processor_and_sender(default_config) do |ep, sender|
413
+ e = { kind: "alias", key: "a", contextKind: "user", previousKey: "b", previousContextKind: "user" }
414
+ ep.add_event(e)
415
+
416
+ output = flush_and_get_events(ep, sender)
417
+ expect(output).to contain_exactly(e)
418
+ end
419
+ end
420
+
411
421
  it "treats nil value for custom the same as an empty hash" do
412
422
  with_processor_and_sender(default_config) do |ep, sender|
413
423
  user_with_nil_custom = { key: "userkey", custom: nil }
data/spec/http_util.rb CHANGED
@@ -3,7 +3,7 @@ require "webrick/httpproxy"
3
3
  require "webrick/https"
4
4
 
5
5
  class StubHTTPServer
6
- attr_reader :requests
6
+ attr_reader :requests, :port
7
7
 
8
8
  @@next_port = 50000
9
9
 
@@ -120,3 +120,13 @@ def with_server(server = nil)
120
120
  server.stop
121
121
  end
122
122
  end
123
+
124
+ class SocketFactoryFromHash
125
+ def initialize(ports = {})
126
+ @ports = ports
127
+ end
128
+
129
+ def open(uri, timeout)
130
+ TCPSocket.new 'localhost', @ports[uri]
131
+ end
132
+ end
@@ -0,0 +1,216 @@
1
+ require "spec_helper"
2
+
3
+ describe LaunchDarkly::Impl::EvaluatorBucketing do
4
+ subject { LaunchDarkly::Impl::EvaluatorBucketing }
5
+
6
+ describe "bucket_user" do
7
+ describe "seed exists" do
8
+ let(:seed) { 61 }
9
+ it "returns the expected bucket values for seed" do
10
+ user = { key: "userKeyA" }
11
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
12
+ expect(bucket).to be_within(0.0000001).of(0.09801207);
13
+
14
+ user = { key: "userKeyB" }
15
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
16
+ expect(bucket).to be_within(0.0000001).of(0.14483777);
17
+
18
+ user = { key: "userKeyC" }
19
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
20
+ expect(bucket).to be_within(0.0000001).of(0.9242641);
21
+ end
22
+
23
+ it "returns the same bucket regardless of hashKey and salt" do
24
+ user = { key: "userKeyA" }
25
+ bucket1 = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
26
+ bucket2 = subject.bucket_user(user, "hashKey1", "key", "saltyB", seed)
27
+ bucket3 = subject.bucket_user(user, "hashKey2", "key", "saltyC", seed)
28
+ expect(bucket1).to eq(bucket2)
29
+ expect(bucket2).to eq(bucket3)
30
+ end
31
+
32
+ it "returns a different bucket if the seed is not the same" do
33
+ user = { key: "userKeyA" }
34
+ bucket1 = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
35
+ bucket2 = subject.bucket_user(user, "hashKey1", "key", "saltyB", seed+1)
36
+ expect(bucket1).to_not eq(bucket2)
37
+ end
38
+
39
+ it "returns a different bucket if the user is not the same" do
40
+ user1 = { key: "userKeyA" }
41
+ user2 = { key: "userKeyB" }
42
+ bucket1 = subject.bucket_user(user1, "hashKey", "key", "saltyA", seed)
43
+ bucket2 = subject.bucket_user(user2, "hashKey1", "key", "saltyB", seed)
44
+ expect(bucket1).to_not eq(bucket2)
45
+ end
46
+ end
47
+
48
+ it "gets expected bucket values for specific keys" do
49
+ user = { key: "userKeyA" }
50
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
51
+ expect(bucket).to be_within(0.0000001).of(0.42157587);
52
+
53
+ user = { key: "userKeyB" }
54
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
55
+ expect(bucket).to be_within(0.0000001).of(0.6708485);
56
+
57
+ user = { key: "userKeyC" }
58
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
59
+ expect(bucket).to be_within(0.0000001).of(0.10343106);
60
+ end
61
+
62
+ it "can bucket by int value (equivalent to string)" do
63
+ user = {
64
+ key: "userkey",
65
+ custom: {
66
+ stringAttr: "33333",
67
+ intAttr: 33333
68
+ }
69
+ }
70
+ stringResult = subject.bucket_user(user, "hashKey", "stringAttr", "saltyA", nil)
71
+ intResult = subject.bucket_user(user, "hashKey", "intAttr", "saltyA", nil)
72
+
73
+ expect(intResult).to be_within(0.0000001).of(0.54771423)
74
+ expect(intResult).to eq(stringResult)
75
+ end
76
+
77
+ it "cannot bucket by float value" do
78
+ user = {
79
+ key: "userkey",
80
+ custom: {
81
+ floatAttr: 33.5
82
+ }
83
+ }
84
+ result = subject.bucket_user(user, "hashKey", "floatAttr", "saltyA", nil)
85
+ expect(result).to eq(0.0)
86
+ end
87
+
88
+
89
+ it "cannot bucket by bool value" do
90
+ user = {
91
+ key: "userkey",
92
+ custom: {
93
+ boolAttr: true
94
+ }
95
+ }
96
+ result = subject.bucket_user(user, "hashKey", "boolAttr", "saltyA", nil)
97
+ expect(result).to eq(0.0)
98
+ end
99
+ end
100
+
101
+ describe "variation_index_for_user" do
102
+ context "rollout is not an experiment" do
103
+ it "matches bucket" do
104
+ user = { key: "userkey" }
105
+ flag_key = "flagkey"
106
+ salt = "salt"
107
+
108
+ # First verify that with our test inputs, the bucket value will be greater than zero and less than 100000,
109
+ # so we can construct a rollout whose second bucket just barely contains that value
110
+ bucket_value = (subject.bucket_user(user, flag_key, "key", salt, nil) * 100000).truncate()
111
+ expect(bucket_value).to be > 0
112
+ expect(bucket_value).to be < 100000
113
+
114
+ bad_variation_a = 0
115
+ matched_variation = 1
116
+ bad_variation_b = 2
117
+ rule = {
118
+ rollout: {
119
+ variations: [
120
+ { variation: bad_variation_a, weight: bucket_value }, # end of bucket range is not inclusive, so it will *not* match the target value
121
+ { variation: matched_variation, weight: 1 }, # size of this bucket is 1, so it only matches that specific value
122
+ { variation: bad_variation_b, weight: 100000 - (bucket_value + 1) }
123
+ ]
124
+ }
125
+ }
126
+ flag = { key: flag_key, salt: salt }
127
+
128
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user)
129
+ expect(result_variation).to be matched_variation
130
+ expect(inExperiment).to be(false)
131
+ end
132
+
133
+ it "uses last bucket if bucket value is equal to total weight" do
134
+ user = { key: "userkey" }
135
+ flag_key = "flagkey"
136
+ salt = "salt"
137
+
138
+ bucket_value = (subject.bucket_user(user, flag_key, "key", salt, nil) * 100000).truncate()
139
+
140
+ # We'll construct a list of variations that stops right at the target bucket value
141
+ rule = {
142
+ rollout: {
143
+ variations: [
144
+ { variation: 0, weight: bucket_value }
145
+ ]
146
+ }
147
+ }
148
+ flag = { key: flag_key, salt: salt }
149
+
150
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user)
151
+ expect(result_variation).to be 0
152
+ expect(inExperiment).to be(false)
153
+ end
154
+ end
155
+ end
156
+
157
+ context "rollout is an experiment" do
158
+ it "returns whether user is in the experiment or not" do
159
+ user1 = { key: "userKeyA" }
160
+ user2 = { key: "userKeyB" }
161
+ user3 = { key: "userKeyC" }
162
+ flag_key = "flagkey"
163
+ salt = "salt"
164
+ seed = 61
165
+
166
+
167
+ rule = {
168
+ rollout: {
169
+ seed: seed,
170
+ kind: 'experiment',
171
+ variations: [
172
+ { variation: 0, weight: 10000, untracked: false },
173
+ { variation: 2, weight: 20000, untracked: false },
174
+ { variation: 0, weight: 70000 , untracked: true }
175
+ ]
176
+ }
177
+ }
178
+ flag = { key: flag_key, salt: salt }
179
+
180
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user1)
181
+ expect(result_variation).to be(0)
182
+ expect(inExperiment).to be(true)
183
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user2)
184
+ expect(result_variation).to be(2)
185
+ expect(inExperiment).to be(true)
186
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user3)
187
+ expect(result_variation).to be(0)
188
+ expect(inExperiment).to be(false)
189
+ end
190
+
191
+ it "uses last bucket if bucket value is equal to total weight" do
192
+ user = { key: "userkey" }
193
+ flag_key = "flagkey"
194
+ salt = "salt"
195
+ seed = 61
196
+
197
+ bucket_value = (subject.bucket_user(user, flag_key, "key", salt, seed) * 100000).truncate()
198
+
199
+ # We'll construct a list of variations that stops right at the target bucket value
200
+ rule = {
201
+ rollout: {
202
+ seed: seed,
203
+ kind: 'experiment',
204
+ variations: [
205
+ { variation: 0, weight: bucket_value, untracked: false }
206
+ ]
207
+ }
208
+ }
209
+ flag = { key: flag_key, salt: salt }
210
+
211
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user)
212
+ expect(result_variation).to be 0
213
+ expect(inExperiment).to be(true)
214
+ end
215
+ end
216
+ end