launchdarkly-server-sdk 5.8.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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