From 4307b2224656015b488dcd9517b8d482b83ad87b Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Wed, 31 Jul 2024 10:56:46 -0700 Subject: [PATCH 01/16] Rename AbTestBucket to AbTest AbTests have multiple `buckets`, so this commit renames the class to be a little clearer. --- config/initializers/ab_tests.rb | 6 +++--- lib/{ab_test_bucket.rb => ab_test.rb} | 2 +- .../concerns/idv/acuant_concern_spec.rb | 6 +++--- .../{ab_test_bucket_spec.rb => ab_test_spec.rb} | 16 ++++++++-------- .../{fake_ab_test_bucket.rb => fake_ab_test.rb} | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) rename lib/{ab_test_bucket.rb => ab_test.rb} (98%) rename spec/lib/{ab_test_bucket_spec.rb => ab_test_spec.rb} (81%) rename spec/support/{fake_ab_test_bucket.rb => fake_ab_test.rb} (79%) diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 883a4c1de96..89745c3dbbc 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require 'ab_test_bucket' +require 'ab_test' module AbTests - DOC_AUTH_VENDOR = AbTestBucket.new( + DOC_AUTH_VENDOR = AbTest.new( experiment_name: 'Doc Auth Vendor', buckets: { alternate_vendor: IdentityConfig.store.doc_auth_vendor_randomize ? @@ -12,7 +12,7 @@ module AbTests }.compact, ).freeze - ACUANT_SDK = AbTestBucket.new( + ACUANT_SDK = AbTest.new( experiment_name: 'Acuant SDK Upgrade', buckets: { use_alternate_sdk: IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_enabled ? diff --git a/lib/ab_test_bucket.rb b/lib/ab_test.rb similarity index 98% rename from lib/ab_test_bucket.rb rename to lib/ab_test.rb index 5819dd63f9b..76788d04143 100644 --- a/lib/ab_test_bucket.rb +++ b/lib/ab_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AbTestBucket +class AbTest attr_reader :buckets, :experiment_name, :default_bucket MAX_SHA = (16 ** 64) - 1 diff --git a/spec/controllers/concerns/idv/acuant_concern_spec.rb b/spec/controllers/concerns/idv/acuant_concern_spec.rb index cb0523dfbea..15850d34199 100644 --- a/spec/controllers/concerns/idv/acuant_concern_spec.rb +++ b/spec/controllers/concerns/idv/acuant_concern_spec.rb @@ -32,7 +32,7 @@ def index; end before do stub_const( 'AbTests::ACUANT_SDK', - FakeAbTestBucket.new.tap { |ab| ab.assign(session_uuid => 0) }, + FakeAbTest.new.tap { |ab| ab.assign(session_uuid => 0) }, ) end @@ -55,7 +55,7 @@ def index; end before do stub_const( 'AbTests::ACUANT_SDK', - FakeAbTestBucket.new.tap { |ab| ab.assign(session_uuid => :use_alternate_sdk) }, + FakeAbTest.new.tap { |ab| ab.assign(session_uuid => :use_alternate_sdk) }, ) end @@ -70,7 +70,7 @@ def index; end before do stub_const( 'AbTests::ACUANT_SDK', - FakeAbTestBucket.new.tap { |ab| ab.assign(session_uuid => 0) }, + FakeAbTest.new.tap { |ab| ab.assign(session_uuid => 0) }, ) end diff --git a/spec/lib/ab_test_bucket_spec.rb b/spec/lib/ab_test_spec.rb similarity index 81% rename from spec/lib/ab_test_bucket_spec.rb rename to spec/lib/ab_test_spec.rb index e74940880c6..926e7a4a37c 100644 --- a/spec/lib/ab_test_bucket_spec.rb +++ b/spec/lib/ab_test_spec.rb @@ -1,13 +1,13 @@ require 'rails_helper' -RSpec.describe AbTestBucket do +RSpec.describe AbTest do context 'configured with buckets adding up to less than 100 percent' do let(:foo_percent) { 30 } let(:bar_percent) { 20 } let(:baz_percent) { 40 } let(:default_percent) { 10 } let(:subject) do - AbTestBucket.new( + AbTest.new( experiment_name: 'test', buckets: { foo: foo_percent, bar: bar_percent, baz: baz_percent }, ) @@ -33,7 +33,7 @@ context 'configured with buckets adding up to exactly 100 percent' do let(:subject) do - AbTestBucket.new(experiment_name: 'test', buckets: { foo: 20, bar: 30, baz: 50 }) + AbTest.new(experiment_name: 'test', buckets: { foo: 20, bar: 30, baz: 50 }) end it 'divides random uuids into the buckets with no automatic default' do @@ -48,7 +48,7 @@ end context 'configured with no buckets' do - let(:subject) { AbTestBucket.new(experiment_name: 'test') } + let(:subject) { AbTest.new(experiment_name: 'test') } it 'returns :default' do bucket = subject.bucket(SecureRandom.uuid) @@ -58,7 +58,7 @@ end context 'configured with buckets with string percentages' do - let(:subject) { AbTestBucket.new(experiment_name: 'test', buckets: { foo: '100' }) } + let(:subject) { AbTest.new(experiment_name: 'test', buckets: { foo: '100' }) } it 'converts string percentages to numbers and returns the correct result' do bucket = subject.bucket(SecureRandom.uuid) @@ -68,7 +68,7 @@ end context 'configured with buckets with random strings' do - let(:subject) { AbTestBucket.new(experiment_name: 'test', buckets: { foo: 'foo', bar: 'bar' }) } + let(:subject) { AbTest.new(experiment_name: 'test', buckets: { foo: 'foo', bar: 'bar' }) } it 'converts string to zero percent and returns :default' do bucket = subject.bucket(SecureRandom.uuid) @@ -78,7 +78,7 @@ end context 'configured with buckets adding up to more than 100 percent' do - let(:subject) { AbTestBucket.new(experiment_name: 'test', buckets: { foo: 60, bar: 60 }) } + let(:subject) { AbTest.new(experiment_name: 'test', buckets: { foo: 60, bar: 60 }) } it 'raises a RuntimeError' do expect { subject }.to raise_error(RuntimeError, 'bucket percentages exceed 100') @@ -86,7 +86,7 @@ end context 'misconfigured with buckets in the wrong data structure' do - let(:subject) { AbTestBucket.new(experiment_name: 'test', buckets: [[:foo, 10], [:bar, 20]]) } + let(:subject) { AbTest.new(experiment_name: 'test', buckets: [[:foo, 10], [:bar, 20]]) } it 'raises a RuntimeError' do expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') diff --git a/spec/support/fake_ab_test_bucket.rb b/spec/support/fake_ab_test.rb similarity index 79% rename from spec/support/fake_ab_test_bucket.rb rename to spec/support/fake_ab_test.rb index 612fb63f498..7c349c88383 100644 --- a/spec/support/fake_ab_test_bucket.rb +++ b/spec/support/fake_ab_test.rb @@ -1,5 +1,5 @@ -# Mock version of AbTestBucket, used to pre-assign items to buckets for deterministic tests -class FakeAbTestBucket +# Mock version of AbTest, used to pre-assign items to buckets for deterministic tests +class FakeAbTest attr_reader :discriminator_to_bucket, :all_result def initialize From 3ef512893cfb20c08e80dcc0f59b678a6df47961 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 1 Aug 2024 15:28:34 -0700 Subject: [PATCH 02/16] Refactor ab_test_spec.rb - Move tests into #bucket method block - Set up a `let` for bucket configs --- lib/ab_test.rb | 15 ++--- spec/lib/ab_test_spec.rb | 115 ++++++++++++++++----------------------- 2 files changed, 53 insertions(+), 77 deletions(-) diff --git a/lib/ab_test.rb b/lib/ab_test.rb index 76788d04143..9d6f2a23712 100644 --- a/lib/ab_test.rb +++ b/lib/ab_test.rb @@ -36,16 +36,13 @@ def percent(discriminator) end def valid_bucket_data_structure? - hash_bucket = buckets.is_a?(Hash) - simple_values = true - if hash_bucket - buckets.values.each do |v| - next unless v.is_a?(Hash) || v.is_a?(Array) - simple_values = false - end - end + return false if !buckets.is_a?(Hash) + + buckets.values.each { |v| Float(v) } - hash_bucket && simple_values + true + rescue ArgumentError + false end def ensure_numeric_percentages diff --git a/spec/lib/ab_test_spec.rb b/spec/lib/ab_test_spec.rb index 926e7a4a37c..16261762d4c 100644 --- a/spec/lib/ab_test_spec.rb +++ b/spec/lib/ab_test_spec.rb @@ -1,95 +1,74 @@ require 'rails_helper' RSpec.describe AbTest do - context 'configured with buckets adding up to less than 100 percent' do - let(:foo_percent) { 30 } - let(:bar_percent) { 20 } - let(:baz_percent) { 40 } - let(:default_percent) { 10 } - let(:subject) do - AbTest.new( - experiment_name: 'test', - buckets: { foo: foo_percent, bar: bar_percent, baz: baz_percent }, - ) - end - - let(:foo_uuid) { SecureRandom.uuid } - let(:bar_uuid) { SecureRandom.uuid } - let(:baz_uuid) { SecureRandom.uuid } - let(:default_uuid) { SecureRandom.uuid } - before do - allow(subject).to receive(:percent).with(foo_uuid).and_return(15) - allow(subject).to receive(:percent).with(bar_uuid).and_return(40) - allow(subject).to receive(:percent).with(baz_uuid).and_return(60) - allow(subject).to receive(:percent).with(default_uuid).and_return(95) - end - it 'sorts uuids into the buckets' do - expect(subject.bucket(foo_uuid)).to eq(:foo) - expect(subject.bucket(bar_uuid)).to eq(:bar) - expect(subject.bucket(baz_uuid)).to eq(:baz) - expect(subject.bucket(default_uuid)).to eq(:default) - end + subject do + AbTest.new( + experiment_name: 'test', + buckets:, + ) end - context 'configured with buckets adding up to exactly 100 percent' do - let(:subject) do - AbTest.new(experiment_name: 'test', buckets: { foo: 20, bar: 30, baz: 50 }) - end + let(:buckets) do + {} + end - it 'divides random uuids into the buckets with no automatic default' do - results = {} - 1000.times do - bucket = subject.bucket(SecureRandom.uuid) - results[bucket] = results[bucket].to_i + 1 + describe '#bucket' do + context 'configured with buckets adding up to exactly 100 percent' do + let(:buckets) do + { foo: 20, bar: 30, baz: 50 } end - expect(results[:default]).to be_nil - end - end + it 'divides random uuids into the buckets with no automatic default' do + results = {} + 1000.times do + bucket = subject.bucket(SecureRandom.uuid) + results[bucket] = results[bucket].to_i + 1 + end - context 'configured with no buckets' do - let(:subject) { AbTest.new(experiment_name: 'test') } + expect(results[:default]).to be_nil + end + end - it 'returns :default' do - bucket = subject.bucket(SecureRandom.uuid) + context 'configured with no buckets' do + it 'returns :default' do + bucket = subject.bucket(SecureRandom.uuid) - expect(bucket).to eq :default + expect(bucket).to eq :default + end end - end - context 'configured with buckets with string percentages' do - let(:subject) { AbTest.new(experiment_name: 'test', buckets: { foo: '100' }) } + context 'configured with buckets with string percentages' do + let(:buckets) { { foo: '100' } } - it 'converts string percentages to numbers and returns the correct result' do - bucket = subject.bucket(SecureRandom.uuid) + it 'converts string percentages to numbers and returns the correct result' do + bucket = subject.bucket(SecureRandom.uuid) - expect(bucket).to eq :foo + expect(bucket).to eq :foo + end end - end - - context 'configured with buckets with random strings' do - let(:subject) { AbTest.new(experiment_name: 'test', buckets: { foo: 'foo', bar: 'bar' }) } - it 'converts string to zero percent and returns :default' do - bucket = subject.bucket(SecureRandom.uuid) + context 'configured with buckets with random strings' do + let(:buckets) { { foo: 'foo', bar: 'bar' } } - expect(bucket).to eq :default + it 'raises a RuntimeError' do + expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') + end end - end - context 'configured with buckets adding up to more than 100 percent' do - let(:subject) { AbTest.new(experiment_name: 'test', buckets: { foo: 60, bar: 60 }) } + context 'configured with buckets adding up to more than 100 percent' do + let(:buckets) { { foo: 60, bar: 60 } } - it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'bucket percentages exceed 100') + it 'raises a RuntimeError' do + expect { subject }.to raise_error(RuntimeError, 'bucket percentages exceed 100') + end end - end - context 'misconfigured with buckets in the wrong data structure' do - let(:subject) { AbTest.new(experiment_name: 'test', buckets: [[:foo, 10], [:bar, 20]]) } + context 'misconfigured with buckets in the wrong data structure' do + let(:buckets) { [[:foo, 10], [:bar, 20]] } - it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') + it 'raises a RuntimeError' do + expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') + end end end end From 7f0fbb986aaf6e52c1c41f56cd2fb4b5603c672b Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 2 Aug 2024 11:09:50 -0700 Subject: [PATCH 03/16] Move discriminator calculation into AbTest Provide a proc that can be used to determine a discriminator from user/user_session/service_provider/request. changelog: Internal, A/B testing, Rework A/B testing system --- app/services/idv/session.rb | 8 ++ config/initializers/ab_tests.rb | 29 +++++- lib/ab_test.rb | 40 +++++++- spec/config/initializers/ab_tests_spec.rb | 51 ++++++++++ spec/lib/ab_test_spec.rb | 115 +++++++++++++++++++--- spec/services/idv/session_spec.rb | 17 ++++ 6 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 spec/config/initializers/ab_tests_spec.rb diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index ef69a96c023..06738cde6aa 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -290,6 +290,14 @@ def desktop_selfie_test_mode_enabled? IdentityConfig.store.doc_auth_selfie_desktop_test_mode end + def self.ab_test_discriminator + ->(request:, service_provider:, user:, user_session:) { + # If the user is not logged in, we can't include them in any A/B tests + # that rely on Idv::Session to get a discriminator. + return nil if !user || user.is_a?(AnonymousUser) + } + end + private attr_reader :user_session diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 89745c3dbbc..19530df0c93 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -3,6 +3,27 @@ require 'ab_test' module AbTests + # Helper method that enables using an Idv::Session to calculate a discriminator value. + # If no Idv::Session is available, `nil` is used as the discriminator value. + # @yieldparam [Idv::Session,nil] The current Idv::Session that can be used to determine + # a discriminator value + # @returns [Proc] + def self.idv_session_discriminator + ->(request:, service_provider:, user:, user_session:) do + # If we don't have a logged-in user, we can't use an Idv::Session-based discriminator. + return nil if user.blank? || user.is_a?(AnonymousUser) + + # If we don't have a user session, we _can't_ have an Idv::Session + return nil if user_session.nil? + + yield Idv::Session.new( + current_user: user, + service_provider:, + user_session:, + ) + end + end + DOC_AUTH_VENDOR = AbTest.new( experiment_name: 'Doc Auth Vendor', buckets: { @@ -10,7 +31,9 @@ module AbTests IdentityConfig.store.doc_auth_vendor_randomize_percent : 0, }.compact, - ).freeze + ) do |request:, service_provider:, user:, user_session:| + idv_session_discriminator(request:, service_provider:, user:, user_session:) + end.freeze ACUANT_SDK = AbTest.new( experiment_name: 'Acuant SDK Upgrade', @@ -19,5 +42,7 @@ module AbTests IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_percent : 0, }, - ).freeze + ) do |request:, service_provider:, user:, user_session:| + idv_session_discriminator(request:, service_provider:, user:, user_session:) + end.freeze end diff --git a/lib/ab_test.rb b/lib/ab_test.rb index 9d6f2a23712..fa9689212ca 100644 --- a/lib/ab_test.rb +++ b/lib/ab_test.rb @@ -5,8 +5,19 @@ class AbTest MAX_SHA = (16 ** 64) - 1 - def initialize(experiment_name:, buckets: {}, default_bucket: :default) + # @yieldparam [ActionDispatch::Request] request + # @yieldparam [String,nil] service_provider Issuer string for the service provider associated with + # the current session. + # @yieldparam [User] user + # @yieldparam [Hash] user_session + def initialize( + experiment_name:, + buckets: {}, + default_bucket: :default, + &discriminator + ) @buckets = buckets + @discriminator = discriminator @experiment_name = experiment_name @default_bucket = default_bucket raise 'invalid bucket data structure' unless valid_bucket_data_structure? @@ -14,8 +25,19 @@ def initialize(experiment_name:, buckets: {}, default_bucket: :default) raise 'bucket percentages exceed 100' unless within_100_percent? end - def bucket(discriminator = nil) - return @default_bucket if discriminator.blank? + # @param [ActionDispatch::Request] request + # @param [String,nil] service_provider Issuer string for the service provider associated with + # the current session. + # @param [User] user + # @param [Hash] user_session + def bucket(request:, service_provider:, user:, user_session:) + return nil if no_percentages? + + discriminator = resolve_discriminator( + request:, service_provider:, session:, user:, + user_session: + ) + return nil if discriminator.blank? user_value = percent(discriminator) @@ -31,6 +53,18 @@ def bucket(discriminator = nil) private + def resolve_discriminator(user:, **) + if @discriminator + @discriminator.call(user:, **) + elsif !user.is_a?(AnonymousUser) + user&.uuid + end + end + + def no_percentages? + buckets.empty? || buckets.values.all? { |pct| pct == 0 } + end + def percent(discriminator) Digest::SHA256.hexdigest("#{discriminator}:#{experiment_name}").to_i(16).to_f / MAX_SHA * 100 end diff --git a/spec/config/initializers/ab_tests_spec.rb b/spec/config/initializers/ab_tests_spec.rb new file mode 100644 index 00000000000..9cc28525243 --- /dev/null +++ b/spec/config/initializers/ab_tests_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe AbTests do + describe '#all' do + it 'returns all registered A/B tests' do + expect(AbTests.all).to match( + { + ACUANT_SDK: an_instance_of(AbTest), + DOC_AUTH_VENDOR: an_instance_of(AbTest), + + }, + ) + end + end + + describe '#idv_session_discriminator' do + let(:request) { spy } + let(:user) { build(:user) } + let(:user_session) { {} } + let(:service_provider) {} + + subject(:discriminator) do + AbTests.idv_session_discriminator do |idv_session| + !!idv_session + end.call( + request:, + user:, + user_session:, + service_provider:, + ) + end + + it 'returns a discriminator value' do + expect(discriminator).to be + end + + context 'when user is nil' do + let(:user) {} + it 'returns nil' do + expect(discriminator).to be_nil + end + end + + context 'when user_session is nil' do + let(:user_session) {} + it 'returns nil' do + expect(discriminator).to be_nil + end + end + end +end diff --git a/spec/lib/ab_test_spec.rb b/spec/lib/ab_test_spec.rb index 16261762d4c..498d1358fa1 100644 --- a/spec/lib/ab_test_spec.rb +++ b/spec/lib/ab_test_spec.rb @@ -5,35 +5,122 @@ AbTest.new( experiment_name: 'test', buckets:, + &discriminator ) end + let(:discriminator) do + ->(**) { SecureRandom.uuid } + end + let(:buckets) do - {} + { foo: 20, bar: 30, baz: 50 } + end + + let(:request) {} + + let(:service_provider) {} + + let(:user) { build(:user) } + + let(:session) { {} } + + let(:user_session) { {} } + + let(:bucket) do + subject.bucket( + request:, + service_provider:, + session:, + user:, + user_session:, + ) end describe '#bucket' do - context 'configured with buckets adding up to exactly 100 percent' do - let(:buckets) do - { foo: 20, bar: 30, baz: 50 } + it 'divides random uuids into the buckets with no automatic default' do + results = {} + 1000.times do + b = subject.bucket( + request:, + service_provider:, + session:, + user:, + user_session:, + ) + results[b] = results[b].to_i + 1 end - it 'divides random uuids into the buckets with no automatic default' do - results = {} - 1000.times do - bucket = subject.bucket(SecureRandom.uuid) - results[bucket] = results[bucket].to_i + 1 + expect(results[:default]).to be_nil + end + + describe 'discriminator invocation' do + let(:discriminator) do + ->(request:, service_provider:, user:, user_session:) { + } + end + it 'passes arguments to discriminator' do + expect(discriminator).to receive(:call). + once. + with( + request:, + service_provider:, + session:, + user:, + user_session:, + ) + + bucket + end + end + + context 'when no discriminator block provided' do + let(:discriminator) { nil } + context 'and user is known' do + let(:user) do + build(:user, uuid: 'some-random-uuid') + end + it 'uses uuid as discriminator' do + expect(subject).to receive(:percent).with('some-random-uuid').once.and_call_original + expect(bucket).to eql(:foo) + end + end + context 'and user is not known' do + let(:user) { nil } + it 'returns nil' do + expect(bucket).to be_nil end + end + context 'and user is anonymous' do + let(:user) { AnonymousUser.new } + it 'does not assign a bucket' do + expect(bucket).to be_nil + end + end + end + + context 'when discriminator returns nil' do + let(:discriminator) do + ->(**) {} + end - expect(results[:default]).to be_nil + it 'returns nil for bucket' do + expect(bucket).to be_nil end end context 'configured with no buckets' do - it 'returns :default' do - bucket = subject.bucket(SecureRandom.uuid) + let(:buckets) { {} } - expect(bucket).to eq :default + it 'returns nil' do + expect(bucket).to be_nil + end + end + + context 'configured with buckets that are all 0' do + let(:buckets) { { foo: 0, bar: 0 } } + it 'returns nil for bucket' do + expect(bucket).to be_nil end end @@ -41,8 +128,6 @@ let(:buckets) { { foo: '100' } } it 'converts string percentages to numbers and returns the correct result' do - bucket = subject.bucket(SecureRandom.uuid) - expect(bucket).to eq :foo end end diff --git a/spec/services/idv/session_spec.rb b/spec/services/idv/session_spec.rb index e7009ef5352..500fcc751a7 100644 --- a/spec/services/idv/session_spec.rb +++ b/spec/services/idv/session_spec.rb @@ -423,4 +423,21 @@ expect(subject.address_mechanism_chosen?).to eq(false) end end + + describe '#ab_test_discriminator' do + context 'when user is logged in' do + it 'works' do + discriminator = described_class.ab_test_discriminator do |idv_session| + expect(idv_session).not_to be_nil + end + + discriminator.call( + request: nil, + user_session: nil, + user: nil, + service_provider: nil, + ) + end + end + end end From d6f397b4b6c54e2f15b5587240d99132b9e05970 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 2 Aug 2024 13:28:16 -0700 Subject: [PATCH 04/16] Automatically log AB tests with analytics events. Augment analytics events with a top-level `ab_tests` property that lists each active test and which bucket the event is in. (This will likely break a lot of tests) --- app/services/analytics.rb | 25 +++++++++++++++++++++++ config/initializers/ab_tests.rb | 5 +++++ spec/services/analytics_spec.rb | 35 +++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/app/services/analytics.rb b/app/services/analytics.rb index a198d653053..908776a5e6a 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -28,6 +28,7 @@ def track_event(event, attributes = {}) analytics_hash.merge!(request_attributes) if request analytics_hash.merge!(sp_request_attributes) if sp_request_attributes + analytics_hash.merge!(ab_test_attributes) ahoy.track(event, analytics_hash) @@ -71,6 +72,30 @@ def request_attributes attributes.merge!(browser_attributes) end + def ab_test_attributes + user_session = session.dig('warden.user.user.session') + ab_tests = AbTests.all.each_with_object({}) do |(test_id, test), obj| + bucket = test.bucket( + request:, + service_provider: sp, + session:, + user:, + user_session:, + ) + if !bucket.blank? + obj[test_id.downcase] = { + bucket:, + } + end + end + + ab_tests.empty? ? + {} : + { + ab_tests: ab_tests, + } + end + def browser @browser ||= BrowserCache.parse(request.user_agent) end diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 19530df0c93..29ed9e6aac9 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -24,6 +24,11 @@ def self.idv_session_discriminator end end + # @returns [Hash] + def self.all + constants.index_with { |test_name| const_get(test_name) } + end + DOC_AUTH_VENDOR = AbTest.new( experiment_name: 'Doc Auth Vendor', buckets: { diff --git a/spec/services/analytics_spec.rb b/spec/services/analytics_spec.rb index 5daebdc5789..31c7cf37240 100644 --- a/spec/services/analytics_spec.rb +++ b/spec/services/analytics_spec.rb @@ -143,6 +143,41 @@ ) end.to_not raise_error end + + context 'with A/B tests' do + let(:ab_tests) do + { + FOO_TEST: AbTest.new( + experiment_name: 'Test 1', + buckets: { + bucket_a: 50, + bucket_b: 50, + }, + ) do |user:, **| + user.id + end, + } + end + + before do + allow(AbTests).to receive(:all).and_return(ab_tests) + end + + it 'includes ab_tests in logged event' do + expect(ahoy).to receive(:track).with( + 'Trackable Event', + analytics_attributes.merge( + ab_tests: { + foo_test: { + bucket: anything, + }, + }, + ), + ) + + analytics.track_event('Trackable Event') + end + end end it 'tracks session duration' do From 80734a1fc1aa22fbd22218cff3249059afe80daa Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 2 Aug 2024 14:53:34 -0700 Subject: [PATCH 05/16] Add AbTestingConcern - Add new method, ab_test_bucket, for controllers to figure out what bucket the user is in --- app/controllers/application_controller.rb | 1 + .../concerns/ab_testing_concern.rb | 19 +++++ .../concerns/ab_testing_concern_spec.rb | 72 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 app/controllers/concerns/ab_testing_concern.rb create mode 100644 spec/controllers/concerns/ab_testing_concern_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2f0dff19bab..047cd2de76b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,6 +7,7 @@ class ApplicationController < ActionController::Base include VerifySpAttributesConcern include SecondMfaReminderConcern include TwoFactorAuthenticatableMethods + include AbTestingConcern # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. diff --git a/app/controllers/concerns/ab_testing_concern.rb b/app/controllers/concerns/ab_testing_concern.rb new file mode 100644 index 00000000000..16eb6bb2769 --- /dev/null +++ b/app/controllers/concerns/ab_testing_concern.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AbTestingConcern + # @param [Symbol] test Name of the test, which should correspond to an A/B test defined in + # # config/initializer/ab_tests.rb. + # @return [Symbol,nil] Bucket to use for the given test, or nil if the test is not active. + def ab_test_bucket(test_name) + test = AbTests.all[test_name] + raise "Unknown A/B test: #{test_name}" unless test + + test.bucket( + request:, + service_provider: current_sp&.issuer, + session:, + user: current_user, + user_session:, + ) + end +end diff --git a/spec/controllers/concerns/ab_testing_concern_spec.rb b/spec/controllers/concerns/ab_testing_concern_spec.rb new file mode 100644 index 00000000000..94898cd827e --- /dev/null +++ b/spec/controllers/concerns/ab_testing_concern_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe AbTestingConcern do + let(:ab_test) do + AbTest.new( + experiment_name: 'Test Test', + buckets: { + foo: 50, + bar: 50, + }, + ) { |user:, **| user.uuid } + end + + let(:ab_tests) do + { + TEST_TEST: ab_test, + } + end + + before do + allow(AbTests).to receive(:all).and_return(ab_tests) + end + + let(:controller_class) do + Class.new do + include AbTestingConcern + attr_accessor :current_user, :current_sp, :request, :session, :user_session + end + end + + let(:user) { build(:user) } + + let(:service_provider) { build(:service_provider) } + + let(:request) { spy } + + let(:session) { {} } + + let(:user_session) { {} } + + subject do + controller_class.new.tap do |c| + c.current_user = user + c.current_sp = service_provider + c.request = request + c.session = session + c.user_session = user_session + end + end + + describe '#ab_test_bucket' do + it 'returns a bucket' do + expect(ab_test).to receive(:bucket).with( + user:, + request:, + service_provider: service_provider.issuer, + session:, + user_session:, + ).and_call_original + + expect(subject.ab_test_bucket(:TEST_TEST)).to eql(:foo).or(eql(:bar)) + end + + context 'for a non-existant test' do + it 'raises a RuntimeError' do + expect do + subject.ab_test_bucket(:NOT_A_REAL_TEST) + end.to raise_error RuntimeError, 'Unknown A/B test: NOT_A_REAL_TEST' + end + end + end +end From 846b61130a204e348b69ab79e9906132c85870e3 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 2 Aug 2024 14:55:04 -0700 Subject: [PATCH 06/16] Update ACUANT_SDK AB test to use new system --- .../concerns/idv/ab_test_analytics_concern.rb | 2 +- .../concerns/idv/acuant_concern.rb | 14 +- .../idv/ab_test_analytics_concern_spec.rb | 12 -- .../concerns/idv/acuant_concern_spec.rb | 29 ++- spec/features/idv/analytics_spec.rb | 179 +++++++++--------- spec/support/fake_ab_test.rb | 22 --- 6 files changed, 107 insertions(+), 151 deletions(-) delete mode 100644 spec/support/fake_ab_test.rb diff --git a/app/controllers/concerns/idv/ab_test_analytics_concern.rb b/app/controllers/concerns/idv/ab_test_analytics_concern.rb index 241218c681c..f5e9f746a65 100644 --- a/app/controllers/concerns/idv/ab_test_analytics_concern.rb +++ b/app/controllers/concerns/idv/ab_test_analytics_concern.rb @@ -13,7 +13,7 @@ def ab_test_analytics_buckets buckets = buckets.merge(opt_in_analytics_properties) end - buckets.merge(acuant_sdk_ab_test_analytics_args) + buckets end end end diff --git a/app/controllers/concerns/idv/acuant_concern.rb b/app/controllers/concerns/idv/acuant_concern.rb index 738c782303b..f6b6a8bb521 100644 --- a/app/controllers/concerns/idv/acuant_concern.rb +++ b/app/controllers/concerns/idv/acuant_concern.rb @@ -2,18 +2,12 @@ module Idv module AcuantConcern - def acuant_sdk_ab_test_analytics_args - return {} if document_capture_session_uuid.blank? - - { - acuant_sdk_upgrade_ab_test_bucket: - AbTests::ACUANT_SDK.bucket(document_capture_session_uuid), - } - end + include AbTestingConcern def acuant_sdk_upgrade_a_b_testing_variables - bucket = AbTests::ACUANT_SDK.bucket(document_capture_session_uuid) - testing_enabled = IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_enabled + bucket = ab_test_bucket(:ACUANT_SDK) + testing_enabled = IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_enabled && + bucket.present? use_alternate_sdk = (bucket == :use_alternate_sdk) if use_alternate_sdk diff --git a/spec/controllers/concerns/idv/ab_test_analytics_concern_spec.rb b/spec/controllers/concerns/idv/ab_test_analytics_concern_spec.rb index 30b0a20c078..b56d7ffa9c9 100644 --- a/spec/controllers/concerns/idv/ab_test_analytics_concern_spec.rb +++ b/spec/controllers/concerns/idv/ab_test_analytics_concern_spec.rb @@ -19,8 +19,6 @@ def document_capture_session_uuid before do allow(subject).to receive(:current_user).and_return(user) - expect(subject).to receive(:acuant_sdk_ab_test_analytics_args). - and_return(acuant_sdk_args) end context 'idv_session is available' do @@ -29,10 +27,6 @@ def document_capture_session_uuid allow(subject).to receive(:idv_session).and_return(idv_session) end - it 'includes acuant_sdk_ab_test_analytics_args' do - expect(controller.ab_test_analytics_buckets).to include(acuant_sdk_args) - end - it 'includes skip_hybrid_handoff' do idv_session.skip_hybrid_handoff = :shh_value expect(controller.ab_test_analytics_buckets).to include({ skip_hybrid_handoff: :shh_value }) @@ -56,11 +50,5 @@ def document_capture_session_uuid end end end - - context 'idv_session is not available' do - it 'still includes acuant_sdk_ab_test_analytics_args' do - expect(controller.ab_test_analytics_buckets).to include(acuant_sdk_args) - end - end end end diff --git a/spec/controllers/concerns/idv/acuant_concern_spec.rb b/spec/controllers/concerns/idv/acuant_concern_spec.rb index 15850d34199..5a278fb552e 100644 --- a/spec/controllers/concerns/idv/acuant_concern_spec.rb +++ b/spec/controllers/concerns/idv/acuant_concern_spec.rb @@ -12,11 +12,21 @@ def index; end let(:default_sdk_version) { IdentityConfig.store.idv_acuant_sdk_version_default } let(:alternate_sdk_version) { IdentityConfig.store.idv_acuant_sdk_version_alternate } + let(:ab_test_bucket) { nil } + subject(:variables) { controller.acuant_sdk_upgrade_a_b_testing_variables } before do allow(controller).to receive(:document_capture_session_uuid). and_return(session_uuid) + + # ACUANT_SDK is frozen, so we have to work with a copy of it + ab_test = AbTests::ACUANT_SDK.dup + allow(ab_test).to receive(:bucket).and_return(ab_test_bucket) + stub_const( + 'AbTests::ACUANT_SDK', + ab_test, + ) end context 'with acuant sdk upgrade A/B testing disabled' do @@ -30,10 +40,7 @@ def index; end context 'and A/B test specifies the older acuant version' do before do - stub_const( - 'AbTests::ACUANT_SDK', - FakeAbTest.new.tap { |ab| ab.assign(session_uuid => 0) }, - ) + allow(AbTests::ACUANT_SDK).to receive(:bucket).and_return(nil) end it 'passes correct variables and acuant version when older is specified' do @@ -52,12 +59,7 @@ def index; end end context 'and A/B test specifies the newer acuant version' do - before do - stub_const( - 'AbTests::ACUANT_SDK', - FakeAbTest.new.tap { |ab| ab.assign(session_uuid => :use_alternate_sdk) }, - ) - end + let(:ab_test_bucket) { :use_alternate_sdk } it 'passes correct variables and acuant version when newer is specified' do expect(variables[:acuant_sdk_upgrade_a_b_testing_enabled]).to eq(true) @@ -67,12 +69,7 @@ def index; end end context 'and A/B test specifies the older acuant version' do - before do - stub_const( - 'AbTests::ACUANT_SDK', - FakeAbTest.new.tap { |ab| ab.assign(session_uuid => 0) }, - ) - end + let(:ab_test_bucket) { :default } it 'passes correct variables and acuant version when older is specified' do expect(variables[:acuant_sdk_upgrade_a_b_testing_enabled]).to eq(true) diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 36cced93ee1..afc18f7c86b 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -148,22 +148,22 @@ step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { - step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: consent checkbox toggled' => { checked: true, }, 'IdV: doc auth agreement submitted' => { - success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: doc auth hybrid handoff visited' => { - step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth hybrid handoff submitted' => { - success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth document_capture visited' => { - flow_path: 'standard', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean + flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean }, 'Frontend: IdV: front image added' => { width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: kind_of(String), fingerprint: anything, failedImageResubmission: boolean, liveness_checking_required: boolean @@ -179,30 +179,30 @@ success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present' }, 'IdV: doc auth document_capture submitted' => { - success: true, errors: {}, flow_path: 'standard', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean + success: true, errors: {}, flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean }, 'IdV: doc auth ssn visited' => { - flow_path: 'standard', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'ssn', analytics_id: 'Doc Auth' }, 'IdV: doc auth ssn submitted' => { - success: true, errors: {}, flow_path: 'standard', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + success: true, errors: {}, flow_path: 'standard', step: 'ssn', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify visited' => { - flow_path: 'standard', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify submitted' => { - flow_path: 'standard', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify proofing results' => { - success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, + success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', proofing_results: base_proofing_results }, 'IdV: phone of record visited' => { - acuant_sdk_upgrade_ab_test_bucket: :default, + proofing_components: base_proofing_components, }, 'IdV: phone confirmation form' => { - success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', acuant_sdk_upgrade_ab_test_bucket: :default, otp_delivery_preference: 'sms', + success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', otp_delivery_preference: 'sms', proofing_components: base_proofing_components }, 'IdV: phone confirmation vendor' => { @@ -217,20 +217,20 @@ proofing_components: lexis_nexis_address_proofing_components, }, 'IdV: phone confirmation otp submitted' => { - success: true, code_expired: false, code_matches: true, otp_delivery_preference: :sms, second_factor_attempts_count: 0, errors: {}, acuant_sdk_upgrade_ab_test_bucket: :default, + success: true, code_expired: false, code_matches: true, otp_delivery_preference: :sms, second_factor_attempts_count: 0, errors: {}, proofing_components: lexis_nexis_address_proofing_components }, :idv_enter_password_visited => { - address_verification_method: 'phone', acuant_sdk_upgrade_ab_test_bucket: :default, - proofing_components: lexis_nexis_address_proofing_components + address_verification_method: 'phone', + proofing_components: lexis_nexis_address_proofing_components, }, :idv_enter_password_submitted => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, active_profile_idv_level: 'legacy_unsupervised', proofing_components: lexis_nexis_address_proofing_components }, 'IdV: final resolution' => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, active_profile_idv_level: 'legacy_unsupervised', profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: lexis_nexis_address_proofing_components @@ -263,22 +263,22 @@ step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { - step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: consent checkbox toggled' => { checked: true, }, 'IdV: doc auth agreement submitted' => { - success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: doc auth hybrid handoff visited' => { - step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth hybrid handoff submitted' => { - success: true, errors: hash_including(message: nil), destination: :link_sent, flow_path: 'hybrid', step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', telephony_response: hash_including(errors: {}, message_id: 'fake-message-id', request_id: 'fake-message-request-id', success: true), selfie_check_required: boolean + success: true, errors: hash_including(message: nil), destination: :link_sent, flow_path: 'hybrid', step: 'hybrid_handoff', analytics_id: 'Doc Auth', telephony_response: hash_including(errors: {}, message_id: 'fake-message-id', request_id: 'fake-message-request-id', success: true), selfie_check_required: boolean }, 'IdV: doc auth document_capture visited' => { - flow_path: 'hybrid', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean + flow_path: 'hybrid', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean }, 'Frontend: IdV: front image added' => { width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'hybrid', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: kind_of(String), fingerprint: anything, failedImageResubmission: boolean, liveness_checking_required: boolean @@ -294,30 +294,30 @@ success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'hybrid', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present' }, 'IdV: doc auth document_capture submitted' => { - success: true, errors: {}, flow_path: 'hybrid', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean + success: true, errors: {}, flow_path: 'hybrid', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean }, 'IdV: doc auth ssn visited' => { - flow_path: 'hybrid', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'hybrid', step: 'ssn', analytics_id: 'Doc Auth' }, 'IdV: doc auth ssn submitted' => { - success: true, errors: {}, flow_path: 'hybrid', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + success: true, errors: {}, flow_path: 'hybrid', step: 'ssn', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify visited' => { - flow_path: 'hybrid', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'hybrid', step: 'verify', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify submitted' => { - flow_path: 'hybrid', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'hybrid', step: 'verify', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify proofing results' => { - success: true, errors: {}, flow_path: 'hybrid', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, + success: true, errors: {}, flow_path: 'hybrid', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', proofing_results: base_proofing_results }, 'IdV: phone of record visited' => { - acuant_sdk_upgrade_ab_test_bucket: :default, + proofing_components: base_proofing_components, }, 'IdV: phone confirmation form' => { - success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', acuant_sdk_upgrade_ab_test_bucket: :default, otp_delivery_preference: 'sms', + success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', otp_delivery_preference: 'sms', proofing_components: base_proofing_components }, 'IdV: phone confirmation vendor' => { @@ -332,20 +332,20 @@ proofing_components: lexis_nexis_address_proofing_components, }, 'IdV: phone confirmation otp submitted' => { - success: true, code_expired: false, code_matches: true, otp_delivery_preference: :sms, second_factor_attempts_count: 0, errors: {}, acuant_sdk_upgrade_ab_test_bucket: :default, + success: true, code_expired: false, code_matches: true, otp_delivery_preference: :sms, second_factor_attempts_count: 0, errors: {}, proofing_components: lexis_nexis_address_proofing_components }, :idv_enter_password_visited => { - address_verification_method: 'phone', acuant_sdk_upgrade_ab_test_bucket: :default, - proofing_components: lexis_nexis_address_proofing_components + address_verification_method: 'phone', + proofing_components: lexis_nexis_address_proofing_components, }, :idv_enter_password_submitted => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, active_profile_idv_level: 'legacy_unsupervised', proofing_components: lexis_nexis_address_proofing_components }, 'IdV: final resolution' => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, active_profile_idv_level: 'legacy_unsupervised', profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: lexis_nexis_address_proofing_components @@ -378,19 +378,19 @@ step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { - step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement submitted' => { - success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: doc auth hybrid handoff visited' => { - step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth hybrid handoff submitted' => { - success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth document_capture visited' => { - flow_path: 'standard', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean + flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean }, 'Frontend: IdV: front image added' => { width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: kind_of(String), fingerprint: anything, failedImageResubmission: boolean, liveness_checking_required: boolean @@ -406,47 +406,46 @@ success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present' }, 'IdV: doc auth document_capture submitted' => { - success: true, errors: {}, flow_path: 'standard', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean + success: true, errors: {}, flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean }, 'IdV: doc auth ssn visited' => { - flow_path: 'standard', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'ssn', analytics_id: 'Doc Auth' }, 'IdV: doc auth ssn submitted' => { - success: true, errors: {}, flow_path: 'standard', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + success: true, errors: {}, flow_path: 'standard', step: 'ssn', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify visited' => { - flow_path: 'standard', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify submitted' => { - flow_path: 'standard', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify proofing results' => { - success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, + success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', proofing_results: base_proofing_results }, 'IdV: phone of record visited' => { - acuant_sdk_upgrade_ab_test_bucket: :default, proofing_components: base_proofing_components, }, 'IdV: USPS address letter requested' => { - resend: false, phone_step_attempts: 0, hours_since_first_letter: 0, acuant_sdk_upgrade_ab_test_bucket: :default, + resend: false, phone_step_attempts: 0, hours_since_first_letter: 0, proofing_components: base_proofing_components }, 'IdV: request letter visited' => {}, :idv_enter_password_visited => { - address_verification_method: 'gpo', acuant_sdk_upgrade_ab_test_bucket: :default, - proofing_components: gpo_letter_proofing_components + address_verification_method: 'gpo', + proofing_components: gpo_letter_proofing_components, }, 'IdV: USPS address letter enqueued' => { - enqueued_at: Time.zone.now.utc, resend: false, phone_step_attempts: 0, first_letter_requested_at: Time.zone.now.utc, hours_since_first_letter: 0, acuant_sdk_upgrade_ab_test_bucket: :default, + enqueued_at: Time.zone.now.utc, resend: false, phone_step_attempts: 0, first_letter_requested_at: Time.zone.now.utc, hours_since_first_letter: 0, proofing_components: gpo_letter_proofing_components }, :idv_enter_password_submitted => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: true, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: true, in_person_verification_pending: false, proofing_components: gpo_letter_proofing_components }, 'IdV: final resolution' => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: true, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: true, in_person_verification_pending: false, # NOTE: pending_profile_idv_level should be set here, a nil value is cached for current_user.pending_profile. profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: gpo_letter_proofing_components @@ -467,19 +466,19 @@ step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { - step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement submitted' => { - success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: doc auth hybrid handoff visited' => { - step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth hybrid handoff submitted' => { - success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth document_capture visited' => { - flow_path: 'standard', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean + flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean }, 'Frontend: IdV: front image added' => { width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: kind_of(String), fingerprint: anything, failedImageResubmission: boolean, liveness_checking_required: boolean @@ -513,29 +512,29 @@ success: true, flow_path: 'standard', step: 'state_id', step_count: 1, analytics_id: 'In Person Proofing', errors: {}, same_address_as_id: false, birth_year: '1938', document_zip_code: '12345' }, 'IdV: in person proofing address visited' => { - step: 'address', flow_path: 'standard', analytics_id: 'In Person Proofing', same_address_as_id: false, acuant_sdk_upgrade_ab_test_bucket: :default + step: 'address', flow_path: 'standard', analytics_id: 'In Person Proofing', same_address_as_id: false }, 'IdV: in person proofing residential address submitted' => { - success: true, step: 'address', flow_path: 'standard', analytics_id: 'In Person Proofing', errors: {}, same_address_as_id: false, acuant_sdk_upgrade_ab_test_bucket: :default, current_address_zip_code: '59010' + success: true, step: 'address', flow_path: 'standard', analytics_id: 'In Person Proofing', errors: {}, same_address_as_id: false, current_address_zip_code: '59010' }, 'IdV: doc auth ssn visited' => { - analytics_id: 'In Person Proofing', step: 'ssn', flow_path: 'standard', acuant_sdk_upgrade_ab_test_bucket: :default, same_address_as_id: false + analytics_id: 'In Person Proofing', step: 'ssn', flow_path: 'standard', same_address_as_id: false }, 'IdV: doc auth ssn submitted' => { - analytics_id: 'In Person Proofing', success: true, step: 'ssn', flow_path: 'standard', errors: {}, acuant_sdk_upgrade_ab_test_bucket: :default, same_address_as_id: false + analytics_id: 'In Person Proofing', success: true, step: 'ssn', flow_path: 'standard', errors: {}, same_address_as_id: false }, 'IdV: doc auth verify visited' => { - analytics_id: 'In Person Proofing', step: 'verify', flow_path: 'standard', same_address_as_id: false, acuant_sdk_upgrade_ab_test_bucket: :default + analytics_id: 'In Person Proofing', step: 'verify', flow_path: 'standard', same_address_as_id: false }, 'IdV: doc auth verify submitted' => { - analytics_id: 'In Person Proofing', step: 'verify', flow_path: 'standard', same_address_as_id: false, acuant_sdk_upgrade_ab_test_bucket: :default + analytics_id: 'In Person Proofing', step: 'verify', flow_path: 'standard', same_address_as_id: false }, 'IdV: doc auth verify proofing results' => { - success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'In Person Proofing', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, same_address_as_id: false, + success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'In Person Proofing', step: 'verify', same_address_as_id: false, proofing_results: in_person_path_proofing_results }, 'IdV: phone confirmation form' => { - success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', acuant_sdk_upgrade_ab_test_bucket: :default, otp_delivery_preference: 'sms', + success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', otp_delivery_preference: 'sms', proofing_components: { document_check: 'usps', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', source_check: 'aamva' } }, 'IdV: phone confirmation vendor' => { @@ -550,19 +549,19 @@ proofing_components: { address_check: 'lexis_nexis_address', document_check: 'usps', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', source_check: 'aamva' }, }, 'IdV: phone confirmation otp submitted' => { - success: true, code_expired: false, code_matches: true, otp_delivery_preference: :sms, second_factor_attempts_count: 0, errors: {}, acuant_sdk_upgrade_ab_test_bucket: :default, + success: true, code_expired: false, code_matches: true, otp_delivery_preference: :sms, second_factor_attempts_count: 0, errors: {}, proofing_components: { document_check: 'usps', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' } }, :idv_enter_password_visited => { - acuant_sdk_upgrade_ab_test_bucket: :default, address_verification_method: 'phone', - proofing_components: { document_check: 'usps', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' } + address_verification_method: 'phone', + proofing_components: { document_check: 'usps', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' }, }, :idv_enter_password_submitted => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: true, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: true, proofing_components: { document_check: 'usps', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' } }, 'IdV: final resolution' => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: true, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: true, # NOTE: pending_profile_idv_level should be set here, a nil value is cached for current_user.pending_profile. profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: { document_check: 'usps', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' } @@ -600,22 +599,22 @@ step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { - step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: consent checkbox toggled' => { checked: true, }, 'IdV: doc auth agreement submitted' => { - success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth', acuant_sdk_upgrade_ab_test_bucket: :default + success: true, errors: {}, step: 'agreement', analytics_id: 'Doc Auth' }, 'IdV: doc auth hybrid handoff visited' => { - step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth hybrid handoff submitted' => { - success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'hybrid_handoff', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean + success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'hybrid_handoff', analytics_id: 'Doc Auth', selfie_check_required: boolean }, 'IdV: doc auth document_capture visited' => { - flow_path: 'standard', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: true + flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: true }, 'Frontend: IdV: front image added' => { width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: kind_of(String), fingerprint: anything, failedImageResubmission: boolean, liveness_checking_required: boolean @@ -631,33 +630,33 @@ success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present' }, 'IdV: doc auth document_capture submitted' => { - success: true, errors: {}, flow_path: 'standard', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: true + success: true, errors: {}, flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: true }, :idv_selfie_image_added => { acuant_version: kind_of(String), captureAttempts: 1, fingerprint: 'aIzxkX_iMtoxFOURZr55qkshs53emQKUOr7VfTf6G1Q', flow_path: 'standard', height: 38, mimeType: 'image/png', size: 3694, source: 'upload', width: 284, liveness_checking_required: boolean, selfie_attempts: 0 }, 'IdV: doc auth ssn visited' => { - flow_path: 'standard', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'ssn', analytics_id: 'Doc Auth' }, 'IdV: doc auth ssn submitted' => { - success: true, errors: {}, flow_path: 'standard', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + success: true, errors: {}, flow_path: 'standard', step: 'ssn', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify visited' => { - flow_path: 'standard', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify submitted' => { - flow_path: 'standard', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, analytics_id: 'Doc Auth' + flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, 'IdV: doc auth verify proofing results' => { - success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', acuant_sdk_upgrade_ab_test_bucket: :default, + success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', proofing_results: base_proofing_results }, 'IdV: phone of record visited' => { - acuant_sdk_upgrade_ab_test_bucket: :default, + proofing_components: base_proofing_components, }, 'IdV: phone confirmation form' => { - success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', acuant_sdk_upgrade_ab_test_bucket: :default, otp_delivery_preference: 'sms', + success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', otp_delivery_preference: 'sms', proofing_components: base_proofing_components }, 'IdV: phone confirmation vendor' => { @@ -672,20 +671,20 @@ proofing_components: lexis_nexis_address_proofing_components, }, 'IdV: phone confirmation otp submitted' => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, code_expired: false, code_matches: true, otp_delivery_preference: :sms, second_factor_attempts_count: 0, errors: {}, + success: true, code_expired: false, code_matches: true, otp_delivery_preference: :sms, second_factor_attempts_count: 0, errors: {}, proofing_components: lexis_nexis_address_proofing_components }, :idv_enter_password_visited => { - address_verification_method: 'phone', acuant_sdk_upgrade_ab_test_bucket: :default, - proofing_components: lexis_nexis_address_proofing_components + address_verification_method: 'phone', + proofing_components: lexis_nexis_address_proofing_components, }, :idv_enter_password_submitted => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, active_profile_idv_level: 'unsupervised_with_selfie', proofing_components: lexis_nexis_address_proofing_components }, 'IdV: final resolution' => { - success: true, acuant_sdk_upgrade_ab_test_bucket: :default, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, active_profile_idv_level: 'unsupervised_with_selfie', profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: lexis_nexis_address_proofing_components diff --git a/spec/support/fake_ab_test.rb b/spec/support/fake_ab_test.rb deleted file mode 100644 index 7c349c88383..00000000000 --- a/spec/support/fake_ab_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -# Mock version of AbTest, used to pre-assign items to buckets for deterministic tests -class FakeAbTest - attr_reader :discriminator_to_bucket, :all_result - - def initialize - @discriminator_to_bucket = {} - end - - def bucket(discriminator) - all_result || discriminator_to_bucket.fetch(discriminator, :default) - end - - # @example - # ab.assign('aaa' => :default, 'bbb' => :experiment) - def assign(discriminator_to_bucket) - @discriminator_to_bucket.merge!(discriminator_to_bucket) - end - - def assign_all(bucket) - @all_result = bucket - end -end From 64a03b75b00be5bc2ff5673d3b428835c5e35eba Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 2 Aug 2024 15:08:40 -0700 Subject: [PATCH 07/16] Update DOC_AUTH_VENDOR A/B test to use new system --- .../concerns/idv/doc_auth_vendor_concern.rb | 13 ++++++++ .../concerns/idv/document_capture_concern.rb | 7 ++-- .../idv/document_capture_controller.rb | 1 + .../document_capture_controller.rb | 1 + .../idv/image_uploads_controller.rb | 3 ++ app/forms/idv/api_image_upload_form.rb | 4 ++- app/services/doc_auth_router.rb | 33 ++++++++++++------- app/services/idv/analytics_events_enhancer.rb | 5 ++- app/services/idv/proofing_components.rb | 9 +++-- app/views/idv/document_capture/show.html.erb | 1 + .../document_capture/show.html.erb | 1 + .../idv/shared/_document_capture.html.erb | 2 +- spec/forms/idv/api_image_upload_form_spec.rb | 1 + spec/services/doc_auth_router_spec.rb | 27 ++++----------- spec/services/idv/proofing_components_spec.rb | 1 + .../shared/_document_capture.html.erb_spec.rb | 1 + 16 files changed, 68 insertions(+), 42 deletions(-) create mode 100644 app/controllers/concerns/idv/doc_auth_vendor_concern.rb diff --git a/app/controllers/concerns/idv/doc_auth_vendor_concern.rb b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb new file mode 100644 index 00000000000..25225c3d6a4 --- /dev/null +++ b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Idv + module DocAuthVendorConcern + include AbTestingConcern + + # @returns[String] String identifying the vendor to use for doc auth. + def doc_auth_vendor + bucket = ab_test_bucket(:DOC_AUTH_VENDOR) + DocAuthRouter.doc_auth_vendor_for_bucket(bucket) + end + end +end diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index cc89ac16ba4..bbe1fea8b37 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -4,14 +4,11 @@ module Idv module DocumentCaptureConcern extend ActiveSupport::Concern + include DocAuthVendorConcern + def save_proofing_components(user) return unless user - doc_auth_vendor = DocAuthRouter.doc_auth_vendor( - discriminator: document_capture_session_uuid, - analytics: analytics, - ) - component_attributes = { document_check: doc_auth_vendor, document_type: 'state_id', diff --git a/app/controllers/idv/document_capture_controller.rb b/app/controllers/idv/document_capture_controller.rb index 983fd6a22a6..9815f85d5d4 100644 --- a/app/controllers/idv/document_capture_controller.rb +++ b/app/controllers/idv/document_capture_controller.rb @@ -46,6 +46,7 @@ def update def extra_view_variables { document_capture_session_uuid: document_capture_session_uuid, + mock_client: doc_auth_vendor == 'mock', flow_path: 'standard', sp_name: decorated_sp_session.sp_name, failure_to_proof_url: return_to_sp_failure_to_proof_url(step: 'document_capture'), diff --git a/app/controllers/idv/hybrid_mobile/document_capture_controller.rb b/app/controllers/idv/hybrid_mobile/document_capture_controller.rb index 9487c5cb2cb..a939cb95929 100644 --- a/app/controllers/idv/hybrid_mobile/document_capture_controller.rb +++ b/app/controllers/idv/hybrid_mobile/document_capture_controller.rb @@ -42,6 +42,7 @@ def update def extra_view_variables { flow_path: 'hybrid', + mock_client: doc_auth_vendor == 'mock', document_capture_session_uuid: document_capture_session_uuid, failure_to_proof_url: return_to_sp_failure_to_proof_url(step: 'document_capture'), doc_auth_selfie_capture: resolved_authn_context_result.biometric_comparison?, diff --git a/app/controllers/idv/image_uploads_controller.rb b/app/controllers/idv/image_uploads_controller.rb index 1cfc7aeb8d1..55c2c886ca0 100644 --- a/app/controllers/idv/image_uploads_controller.rb +++ b/app/controllers/idv/image_uploads_controller.rb @@ -2,6 +2,8 @@ module Idv class ImageUploadsController < ApplicationController + include DocAuthVendorConcern + respond_to :json def create @@ -20,6 +22,7 @@ def create def image_upload_form @image_upload_form ||= Idv::ApiImageUploadForm.new( params, + doc_auth_vendor:, service_provider: current_sp, analytics: analytics, uuid_prefix: current_sp&.app_id, diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 70228e8d25f..18a77c8eeb6 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -17,12 +17,14 @@ class ApiImageUploadForm def initialize( params, service_provider:, + doc_auth_vendor:, analytics: nil, uuid_prefix: nil, liveness_checking_required: false ) @params = params @service_provider = service_provider + @doc_auth_vendor = doc_auth_vendor @analytics = analytics @readable = {} @uuid_prefix = uuid_prefix @@ -315,7 +317,7 @@ def document_capture_session_uuid def doc_auth_client @doc_auth_client ||= DocAuthRouter.client( - vendor_discriminator: document_capture_session_uuid, + vendor: @doc_auth_vendor, warn_notifier: proc do |attrs| analytics&.doc_auth_warning( **attrs, diff --git a/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb index 6d91ac9fa57..69516decca7 100644 --- a/app/services/doc_auth_router.rb +++ b/app/services/doc_auth_router.rb @@ -159,8 +159,8 @@ def translate_generic_errors!(response) # rubocop:disable Layout/LineLength # @param [Proc,nil] warn_notifier proc takes a hash, and should log that hash to events.log - def self.client(vendor_discriminator: nil, warn_notifier: nil, analytics: nil) - case doc_auth_vendor(discriminator: vendor_discriminator, analytics: analytics) + def self.client(vendor:, warn_notifier: nil) + case vendor when Idp::Constants::Vendors::LEXIS_NEXIS, 'lexisnexis' # Use constant once configured in prod DocAuthErrorTranslatorProxy.new( DocAuth::LexisNexis::LexisNexisClient.new( @@ -190,19 +190,30 @@ def self.client(vendor_discriminator: nil, warn_notifier: nil, analytics: nil) ), ) else - raise "#{doc_auth_vendor(discriminator: vendor_discriminator)} is not a valid doc auth vendor" + raise "#{vendor} is not a valid doc auth vendor" end end # rubocop:enable Layout/LineLength - def self.doc_auth_vendor(discriminator: nil, analytics: nil) - case AbTests::DOC_AUTH_VENDOR.bucket(discriminator) - when :alternate_vendor - IdentityConfig.store.doc_auth_vendor_randomize_alternate_vendor - else - analytics&.idv_doc_auth_randomizer_defaulted if discriminator.blank? - + def self.doc_auth_vendor_for_bucket(bucket) + bucket == :alternate_vendor ? + IdentityConfig.store.doc_auth_vendor_randomize_alternate_vendor : IdentityConfig.store.doc_auth_vendor - end + end + + def self.doc_auth_vendor( + request:, + service_provider:, + user:, + user_session: + ) + bucket = AbTests::DOC_AUTH_VENDOR.bucket( + request:, + service_provider:, + user:, + user_session:, + ) + + doc_auth_vendor_for_bucket(bucket) end end diff --git a/app/services/idv/analytics_events_enhancer.rb b/app/services/idv/analytics_events_enhancer.rb index f75fd52f412..466c380fcb8 100644 --- a/app/services/idv/analytics_events_enhancer.rb +++ b/app/services/idv/analytics_events_enhancer.rb @@ -185,14 +185,17 @@ def profile_history def proofing_components return if !user + user_session = session&.dig('warden.user.user.session') || {} + idv_session = Idv::Session.new( - user_session: session&.dig('warden.user.user.session') || {}, + user_session:, current_user: user, service_provider: sp, ) proofing_components_hash = ProofingComponents.new( user:, + user_session:, idv_session:, ).to_h diff --git a/app/services/idv/proofing_components.rb b/app/services/idv/proofing_components.rb index 6300bfd8ff3..e8165c0e95c 100644 --- a/app/services/idv/proofing_components.rb +++ b/app/services/idv/proofing_components.rb @@ -4,9 +4,11 @@ module Idv class ProofingComponents def initialize( user:, + user_session:, idv_session: ) @user = user + @user_session = user_session @idv_session = idv_session end @@ -15,7 +17,10 @@ def document_check Idp::Constants::Vendors::USPS elsif idv_session.remote_document_capture_complete? DocAuthRouter.doc_auth_vendor( - discriminator: idv_session.document_capture_session_uuid, + request: nil, + service_provider: idv_session.service_provider, + user_session:, + user:, ) end end @@ -65,6 +70,6 @@ def to_h private - attr_reader :user, :idv_session + attr_reader :user, :user_session, :idv_session end end diff --git a/app/views/idv/document_capture/show.html.erb b/app/views/idv/document_capture/show.html.erb index 79eeba90896..e8f61792451 100644 --- a/app/views/idv/document_capture/show.html.erb +++ b/app/views/idv/document_capture/show.html.erb @@ -12,4 +12,5 @@ skip_doc_auth_from_how_to_verify: skip_doc_auth_from_how_to_verify, skip_doc_auth_from_handoff: skip_doc_auth_from_handoff, doc_auth_selfie_capture: doc_auth_selfie_capture, + mock_client: mock_client, ) %> diff --git a/app/views/idv/hybrid_mobile/document_capture/show.html.erb b/app/views/idv/hybrid_mobile/document_capture/show.html.erb index d6f147562e7..0e1818c2825 100644 --- a/app/views/idv/hybrid_mobile/document_capture/show.html.erb +++ b/app/views/idv/hybrid_mobile/document_capture/show.html.erb @@ -12,4 +12,5 @@ skip_doc_auth_from_how_to_verify: false, skip_doc_auth_from_handoff: nil, doc_auth_selfie_capture: doc_auth_selfie_capture, + mock_client: mock_client, ) %> diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 8a67edf5393..2f2d4d52b6b 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -7,7 +7,7 @@ <%= tag.div id: 'document-capture-form', data: { app_name: APP_NAME, liveness_required: nil, - mock_client: (DocAuthRouter.doc_auth_vendor(discriminator: document_capture_session_uuid) == 'mock').presence, + mock_client: mock_client, help_center_redirect_url: help_center_redirect_url( flow: :idv, step: :document_capture, diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 7b3ee6da010..58b4ea4d279 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -19,6 +19,7 @@ service_provider: build(:service_provider, issuer: 'test_issuer'), analytics: fake_analytics, liveness_checking_required: liveness_checking_required, + doc_auth_vendor: 'mock', ) end diff --git a/spec/services/doc_auth_router_spec.rb b/spec/services/doc_auth_router_spec.rb index caa9f189c3d..76ecdf41e40 100644 --- a/spec/services/doc_auth_router_spec.rb +++ b/spec/services/doc_auth_router_spec.rb @@ -2,24 +2,19 @@ RSpec.describe DocAuthRouter do describe '.client' do - before do - allow(IdentityConfig.store).to receive(:doc_auth_vendor).and_return(doc_auth_vendor) - end - context 'for lexisnexis' do - let(:doc_auth_vendor) { Idp::Constants::Vendors::LEXIS_NEXIS } - + subject do + DocAuthRouter.client(vendor: 'lexisnexis') + end it 'is a translation-proxied lexisnexis client' do - expect(DocAuthRouter.client).to be_a(DocAuthRouter::DocAuthErrorTranslatorProxy) - expect(DocAuthRouter.client.client).to be_a(DocAuth::LexisNexis::LexisNexisClient) + expect(subject).to be_a(DocAuthRouter::DocAuthErrorTranslatorProxy) + expect(subject.client).to be_a(DocAuth::LexisNexis::LexisNexisClient) end end context 'other config' do - let(:doc_auth_vendor) { 'unknown' } - it 'errors' do - expect { DocAuthRouter.client }.to raise_error(RuntimeError) + expect { DocAuthRouter.client(vendor: 'unknown') }.to raise_error(RuntimeError) end end end @@ -60,16 +55,6 @@ def reload_ab_test_initializer! reload_ab_test_initializer! end - - context 'with a nil discriminator' do - it 'is the default vendor, and logs analytics events' do - expect(analytics).to receive(:idv_doc_auth_randomizer_defaulted) - - result = DocAuthRouter.doc_auth_vendor(discriminator: nil, analytics: analytics) - - expect(result).to eq(doc_auth_vendor) - end - end end describe DocAuthRouter::DocAuthErrorTranslatorProxy do diff --git a/spec/services/idv/proofing_components_spec.rb b/spec/services/idv/proofing_components_spec.rb index d6bd1b81b73..c93b15cd96b 100644 --- a/spec/services/idv/proofing_components_spec.rb +++ b/spec/services/idv/proofing_components_spec.rb @@ -20,6 +20,7 @@ subject do described_class.new( user:, + user_session:, idv_session:, ) end diff --git a/spec/views/idv/shared/_document_capture.html.erb_spec.rb b/spec/views/idv/shared/_document_capture.html.erb_spec.rb index 250aa4ecf85..f147c8ddd3f 100644 --- a/spec/views/idv/shared/_document_capture.html.erb_spec.rb +++ b/spec/views/idv/shared/_document_capture.html.erb_spec.rb @@ -55,6 +55,7 @@ skip_doc_auth_from_how_to_verify: skip_doc_auth_from_how_to_verify, skip_doc_auth_from_handoff: skip_doc_auth_from_handoff, opted_in_to_in_person_proofing: opted_in_to_in_person_proofing, + mock_client: nil, } end From 1031578cf9babdfec92376f6a89acd188441a4c8 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 8 Aug 2024 16:17:33 -0700 Subject: [PATCH 08/16] Allow more control over what events log A/B tests should_log can be a Proc, RegExp, etc. and is matched against the event name. --- app/services/analytics.rb | 6 ++- lib/ab_test.rb | 23 ++++++++- spec/lib/ab_test_spec.rb | 92 +++++++++++++++++++++++++++++++++ spec/services/analytics_spec.rb | 15 ++++++ 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 908776a5e6a..8e7c59cf5c4 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -28,7 +28,7 @@ def track_event(event, attributes = {}) analytics_hash.merge!(request_attributes) if request analytics_hash.merge!(sp_request_attributes) if sp_request_attributes - analytics_hash.merge!(ab_test_attributes) + analytics_hash.merge!(ab_test_attributes(event)) ahoy.track(event, analytics_hash) @@ -72,9 +72,11 @@ def request_attributes attributes.merge!(browser_attributes) end - def ab_test_attributes + def ab_test_attributes(event) user_session = session.dig('warden.user.user.session') ab_tests = AbTests.all.each_with_object({}) do |(test_id, test), obj| + next if !test.include_in_analytics_event?(event) + bucket = test.bucket( request:, service_provider: sp, diff --git a/lib/ab_test.rb b/lib/ab_test.rb index fa9689212ca..201f55df060 100644 --- a/lib/ab_test.rb +++ b/lib/ab_test.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true class AbTest - attr_reader :buckets, :experiment_name, :default_bucket + attr_reader :buckets, :experiment_name, :default_bucket, :should_log MAX_SHA = (16 ** 64) - 1 + # @param [Proc,RegExp,string,Boolean,nil] should_log Controls whether bucket data for this + # A/B test is logged with specific + # events. # @yieldparam [ActionDispatch::Request] request # @yieldparam [String,nil] service_provider Issuer string for the service provider associated with # the current session. @@ -13,6 +16,7 @@ class AbTest def initialize( experiment_name:, buckets: {}, + should_log: nil, default_bucket: :default, &discriminator ) @@ -20,6 +24,7 @@ def initialize( @discriminator = discriminator @experiment_name = experiment_name @default_bucket = default_bucket + @should_log = should_log raise 'invalid bucket data structure' unless valid_bucket_data_structure? ensure_numeric_percentages raise 'bucket percentages exceed 100' unless within_100_percent? @@ -51,6 +56,22 @@ def bucket(request:, service_provider:, user:, user_session:) @default_bucket end + def include_in_analytics_event?(event_name) + if should_log.is_a?(Proc) + should_log.call(event_name) + elsif should_log.is_a?(Regexp) + should_log.match?(event_name) + elsif should_log.is_a?(String) + event_name == should_log + elsif should_log == true || should_log == false + should_log + elsif !should_log.nil? + raise 'Unexpected value used for should_log' + else + true + end + end + private def resolve_discriminator(user:, **) diff --git a/spec/lib/ab_test_spec.rb b/spec/lib/ab_test_spec.rb index 498d1358fa1..07288aac131 100644 --- a/spec/lib/ab_test_spec.rb +++ b/spec/lib/ab_test_spec.rb @@ -5,6 +5,7 @@ AbTest.new( experiment_name: 'test', buckets:, + should_log:, &discriminator ) end @@ -17,6 +18,10 @@ { foo: 20, bar: 30, baz: 50 } end + let(:should_log) do + nil + end + let(:request) {} let(:service_provider) {} @@ -156,4 +161,91 @@ end end end + + describe '#include_in_analytics_event?' do + let(:event_name) { 'My cool event' } + + let(:return_value) { subject.include_in_analytics_event?(event_name) } + + context 'when should_log is nil' do + it 'returns true' do + expect(return_value).to eql(true) + end + end + + context 'when string is used' do + context 'and string matches' do + let(:should_log) { event_name } + it 'returns true' do + expect(return_value).to eql(true) + end + end + context 'and string does not match' do + let(:should_log) { "Not #{event_name}" } + it 'returns false' do + expect(return_value).to eql(false) + end + end + end + + context 'when Regexp is used' do + context 'and it matches' do + let(:should_log) { /cool/ } + it 'returns true' do + expect(return_value).to eql(true) + end + end + context 'and it does not match' do + let(:should_log) { /not cool/ } + it 'returns false' do + expect(return_value).to eql(false) + end + end + end + + context 'when Proc is used' do + let(:should_log) do + ->(_event_name) {} + end + + it 'calls the proc' do + expect(should_log).to receive(:call).with(event_name).and_call_original + return_value + end + + context 'and it returns true' do + let(:should_log) do + ->(_event_name) { true } + end + + it 'returns true' do + expect(return_value).to eql(true) + end + end + + context 'and it returns false' do + let(:should_log) do + ->(_event_name) { false } + end + + it 'returns false' do + expect(return_value).to eql(false) + end + end + end + + context 'when true is used' do + let(:should_log) { true } + it 'returns true' do + expect(return_value).to eql(true) + end + end + + context 'when false is used' do + let(:should_log) { false } + it 'returns false' do + expect(return_value).to eql(false) + end + end + end end diff --git a/spec/services/analytics_spec.rb b/spec/services/analytics_spec.rb index 31c7cf37240..9e2bf8dfc2a 100644 --- a/spec/services/analytics_spec.rb +++ b/spec/services/analytics_spec.rb @@ -153,12 +153,15 @@ bucket_a: 50, bucket_b: 50, }, + should_log:, ) do |user:, **| user.id end, } end + let(:should_log) {} + before do allow(AbTests).to receive(:all).and_return(ab_tests) end @@ -177,6 +180,18 @@ analytics.track_event('Trackable Event') end + + context 'when should_log says not to' do + let(:should_log) { false } + it 'does not include ab_test in logged event' do + expect(ahoy).to receive(:track).with( + 'Trackable Event', + analytics_attributes, + ) + + analytics.track_event('Trackable Event') + end + end end end From 09c24e8def871c9f1a3ad6841b97885dc33c979d Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 8 Aug 2024 16:18:58 -0700 Subject: [PATCH 09/16] Limit existing A/B tests to IdV events --- config/initializers/ab_tests.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 29ed9e6aac9..22cc8eb2277 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -31,6 +31,7 @@ def self.all DOC_AUTH_VENDOR = AbTest.new( experiment_name: 'Doc Auth Vendor', + should_log: /^idv/i, buckets: { alternate_vendor: IdentityConfig.store.doc_auth_vendor_randomize ? IdentityConfig.store.doc_auth_vendor_randomize_percent : @@ -42,6 +43,7 @@ def self.all ACUANT_SDK = AbTest.new( experiment_name: 'Acuant SDK Upgrade', + should_log: /^idv/i, buckets: { use_alternate_sdk: IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_enabled ? IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_percent : From 6bcb83b16ae0a7d3838318a9ef2f70bdc86d9fef Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 8 Aug 2024 17:18:59 -0700 Subject: [PATCH 10/16] Improve use of document_capture_session_uuid as a discriminator - Handle case where UUID is present in session (hybrid flow) - Handle case where UUID is in Idv::Session --- app/services/doc_auth_router.rb | 2 + app/services/idv/analytics_events_enhancer.rb | 3 +- app/services/idv/proofing_components.rb | 15 +++-- config/initializers/ab_tests.rb | 42 ++++++------ lib/ab_test.rb | 3 +- spec/config/initializers/ab_tests_spec.rb | 65 ++++++++++++++----- spec/services/idv/proofing_components_spec.rb | 3 + 7 files changed, 87 insertions(+), 46 deletions(-) diff --git a/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb index 69516decca7..650c403b95c 100644 --- a/app/services/doc_auth_router.rb +++ b/app/services/doc_auth_router.rb @@ -204,12 +204,14 @@ def self.doc_auth_vendor_for_bucket(bucket) def self.doc_auth_vendor( request:, service_provider:, + session:, user:, user_session: ) bucket = AbTests::DOC_AUTH_VENDOR.bucket( request:, service_provider:, + session:, user:, user_session:, ) diff --git a/app/services/idv/analytics_events_enhancer.rb b/app/services/idv/analytics_events_enhancer.rb index 466c380fcb8..ab470910044 100644 --- a/app/services/idv/analytics_events_enhancer.rb +++ b/app/services/idv/analytics_events_enhancer.rb @@ -194,9 +194,10 @@ def proofing_components ) proofing_components_hash = ProofingComponents.new( + idv_session:, + session:, user:, user_session:, - idv_session:, ).to_h proofing_components_hash.empty? ? nil : proofing_components_hash diff --git a/app/services/idv/proofing_components.rb b/app/services/idv/proofing_components.rb index e8165c0e95c..f5b2a6e64e3 100644 --- a/app/services/idv/proofing_components.rb +++ b/app/services/idv/proofing_components.rb @@ -3,13 +3,15 @@ module Idv class ProofingComponents def initialize( - user:, - user_session:, - idv_session: - ) + idv_session:, + session:, + user:, + user_session: + ) + @idv_session = idv_session + @session = session @user = user @user_session = user_session - @idv_session = idv_session end def document_check @@ -19,6 +21,7 @@ def document_check DocAuthRouter.doc_auth_vendor( request: nil, service_provider: idv_session.service_provider, + session:, user_session:, user:, ) @@ -70,6 +73,6 @@ def to_h private - attr_reader :user, :user_session, :idv_session + attr_reader :idv_session, :session, :user, :user_session end end diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 22cc8eb2277..ec69864d442 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -3,25 +3,25 @@ require 'ab_test' module AbTests - # Helper method that enables using an Idv::Session to calculate a discriminator value. - # If no Idv::Session is available, `nil` is used as the discriminator value. - # @yieldparam [Idv::Session,nil] The current Idv::Session that can be used to determine - # a discriminator value - # @returns [Proc] - def self.idv_session_discriminator - ->(request:, service_provider:, user:, user_session:) do - # If we don't have a logged-in user, we can't use an Idv::Session-based discriminator. - return nil if user.blank? || user.is_a?(AnonymousUser) + def self.document_capture_session_uuid_discriminator( + service_provider:, + session:, + user:, + user_session: + ) + # If we don't have a user, there _may_ be a document capture session UUID + # sitting in session if the user is currently doing hybrid handoff. + return session[:document_capture_session_uuid] if !user - # If we don't have a user session, we _can't_ have an Idv::Session - return nil if user_session.nil? + # Otherwise, try to get the user's current Idv::Session and read + # the generated document_capture_session UUID from there + return if !user_session - yield Idv::Session.new( - current_user: user, - service_provider:, - user_session:, - ) - end + Idv::Session.new( + current_user: user, + service_provider:, + user_session:, + ).document_capture_session_uuid end # @returns [Hash] @@ -37,8 +37,8 @@ def self.all IdentityConfig.store.doc_auth_vendor_randomize_percent : 0, }.compact, - ) do |request:, service_provider:, user:, user_session:| - idv_session_discriminator(request:, service_provider:, user:, user_session:) + ) do |session:, user:, user_session:, **| + document_capture_session_uuid_discriminator(service_provider:, session:, user:, user_session:) end.freeze ACUANT_SDK = AbTest.new( @@ -49,7 +49,7 @@ def self.all IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_percent : 0, }, - ) do |request:, service_provider:, user:, user_session:| - idv_session_discriminator(request:, service_provider:, user:, user_session:) + ) do |service_provider:, session:, user:, user_session:, **| + document_capture_session_uuid_discriminator(service_provider:, session:, user:, user_session:) end.freeze end diff --git a/lib/ab_test.rb b/lib/ab_test.rb index 201f55df060..35d18a9fe38 100644 --- a/lib/ab_test.rb +++ b/lib/ab_test.rb @@ -33,9 +33,10 @@ def initialize( # @param [ActionDispatch::Request] request # @param [String,nil] service_provider Issuer string for the service provider associated with # the current session. + # @params [Hash] session # @param [User] user # @param [Hash] user_session - def bucket(request:, service_provider:, user:, user_session:) + def bucket(request:, service_provider:, session:, user:, user_session:) return nil if no_percentages? discriminator = resolve_discriminator( diff --git a/spec/config/initializers/ab_tests_spec.rb b/spec/config/initializers/ab_tests_spec.rb index 9cc28525243..78f574de026 100644 --- a/spec/config/initializers/ab_tests_spec.rb +++ b/spec/config/initializers/ab_tests_spec.rb @@ -13,38 +13,69 @@ end end - describe '#idv_session_discriminator' do + describe '#document_capture_session_uuid_discriminator' do let(:request) { spy } let(:user) { build(:user) } + let(:service_provider) {} + let(:session) { {} } let(:user_session) { {} } let(:service_provider) {} subject(:discriminator) do - AbTests.idv_session_discriminator do |idv_session| - !!idv_session - end.call( - request:, + AbTests.document_capture_session_uuid_discriminator( + service_provider:, + session:, user:, user_session:, - service_provider:, ) end - it 'returns a discriminator value' do - expect(discriminator).to be + context 'when document_capture_session_uuid is present in session' do + let(:session) do + { + document_capture_session_uuid: 'super-random-uuid', + } + end + context 'and user is nil' do + let(:user) {} + it 'returns the uuid in session' do + expect(discriminator).to eql('super-random-uuid') + end + end + context 'and user is not nil' do + it 'does not return the uuid in session' do + expect(discriminator).to be_nil + end + end end - context 'when user is nil' do - let(:user) {} - it 'returns nil' do - expect(discriminator).to be_nil + context 'when document_capture_session_uuid is not present in session' do + context 'when user is nil' do + let(:user) {} + it 'returns nil' do + expect(discriminator).to be_nil + end end - end - context 'when user_session is nil' do - let(:user_session) {} - it 'returns nil' do - expect(discriminator).to be_nil + context 'when user_session is nil' do + let(:user_session) {} + it 'returns nil' do + expect(discriminator).to be_nil + end + end + + context 'when user_session contains an Idv::Session with a doc capture session uuid' do + let(:user_session) do + { + idv: { + document_capture_session_uuid: 'super-random-uuid', + }, + } + end + + it 'returns it' do + expect(discriminator).to eql('super-random-uuid') + end end end end diff --git a/spec/services/idv/proofing_components_spec.rb b/spec/services/idv/proofing_components_spec.rb index c93b15cd96b..d78fb48b1fc 100644 --- a/spec/services/idv/proofing_components_spec.rb +++ b/spec/services/idv/proofing_components_spec.rb @@ -5,6 +5,8 @@ let(:user_session) { {} } + let(:session) { {} } + let(:idv_session) do Idv::Session.new( current_user: user, @@ -19,6 +21,7 @@ subject do described_class.new( + session:, user:, user_session:, idv_session:, From f1ec176d3210ff54170f2eafc9d489b6eb4860ba Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 9 Aug 2024 10:40:40 -0700 Subject: [PATCH 11/16] Limit should_log to RegExp only Right now all we're doing with this is checking to see if it's an idv-related event, which we can do with a Regexp. --- lib/ab_test.rb | 8 +---- spec/lib/ab_test_spec.rb | 54 +++------------------------------ spec/services/analytics_spec.rb | 2 +- 3 files changed, 6 insertions(+), 58 deletions(-) diff --git a/lib/ab_test.rb b/lib/ab_test.rb index 35d18a9fe38..ef090aabe43 100644 --- a/lib/ab_test.rb +++ b/lib/ab_test.rb @@ -58,14 +58,8 @@ def bucket(request:, service_provider:, session:, user:, user_session:) end def include_in_analytics_event?(event_name) - if should_log.is_a?(Proc) - should_log.call(event_name) - elsif should_log.is_a?(Regexp) + if should_log.is_a?(Regexp) should_log.match?(event_name) - elsif should_log.is_a?(String) - event_name == should_log - elsif should_log == true || should_log == false - should_log elsif !should_log.nil? raise 'Unexpected value used for should_log' else diff --git a/spec/lib/ab_test_spec.rb b/spec/lib/ab_test_spec.rb index 07288aac131..7226db1b171 100644 --- a/spec/lib/ab_test_spec.rb +++ b/spec/lib/ab_test_spec.rb @@ -173,21 +173,6 @@ end end - context 'when string is used' do - context 'and string matches' do - let(:should_log) { event_name } - it 'returns true' do - expect(return_value).to eql(true) - end - end - context 'and string does not match' do - let(:should_log) { "Not #{event_name}" } - it 'returns false' do - expect(return_value).to eql(false) - end - end - end - context 'when Regexp is used' do context 'and it matches' do let(:should_log) { /cool/ } @@ -203,48 +188,17 @@ end end - context 'when Proc is used' do - let(:should_log) do - ->(_event_name) {} - end - - it 'calls the proc' do - expect(should_log).to receive(:call).with(event_name).and_call_original - return_value - end - - context 'and it returns true' do - let(:should_log) do - ->(_event_name) { true } - end - - it 'returns true' do - expect(return_value).to eql(true) - end - end - - context 'and it returns false' do - let(:should_log) do - ->(_event_name) { false } - end - - it 'returns false' do - expect(return_value).to eql(false) - end - end - end - context 'when true is used' do let(:should_log) { true } - it 'returns true' do - expect(return_value).to eql(true) + it 'raises' do + expect { return_value }.to raise_error end end context 'when false is used' do let(:should_log) { false } - it 'returns false' do - expect(return_value).to eql(false) + it 'raises' do + expect { return_value }.to raise_error end end end diff --git a/spec/services/analytics_spec.rb b/spec/services/analytics_spec.rb index 9e2bf8dfc2a..07b0dd5dc02 100644 --- a/spec/services/analytics_spec.rb +++ b/spec/services/analytics_spec.rb @@ -182,7 +182,7 @@ end context 'when should_log says not to' do - let(:should_log) { false } + let(:should_log) { /some other event/ } it 'does not include ab_test in logged event' do expect(ahoy).to receive(:track).with( 'Trackable Event', From 145caefcc1e2614f6d9114f0c8f3edf6903d563f Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 9 Aug 2024 13:47:39 -0700 Subject: [PATCH 12/16] Pass acuant_sdk_upgrade_ab_test_bucket into ApiImageUploadForm - Tell the form what bucket it's in so that it can log properly - Add test coverage for form submission when Acuant A/B test is enabled --- .../idv/image_uploads_controller.rb | 1 + app/forms/idv/api_image_upload_form.rb | 10 ++++----- spec/forms/idv/api_image_upload_form_spec.rb | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/controllers/idv/image_uploads_controller.rb b/app/controllers/idv/image_uploads_controller.rb index 55c2c886ca0..6f453989985 100644 --- a/app/controllers/idv/image_uploads_controller.rb +++ b/app/controllers/idv/image_uploads_controller.rb @@ -23,6 +23,7 @@ def image_upload_form @image_upload_form ||= Idv::ApiImageUploadForm.new( params, doc_auth_vendor:, + acuant_sdk_upgrade_ab_test_bucket: ab_test_bucket(:ACUANT_SDK), service_provider: current_sp, analytics: analytics, uuid_prefix: current_sp&.app_id, diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 18a77c8eeb6..42c35c9c074 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -18,6 +18,7 @@ def initialize( params, service_provider:, doc_auth_vendor:, + acuant_sdk_upgrade_ab_test_bucket:, analytics: nil, uuid_prefix: nil, liveness_checking_required: false @@ -25,6 +26,7 @@ def initialize( @params = params @service_provider = service_provider @doc_auth_vendor = doc_auth_vendor + @acuant_sdk_upgrade_ab_test_bucket = acuant_sdk_upgrade_ab_test_bucket @analytics = analytics @readable = {} @uuid_prefix = uuid_prefix @@ -63,7 +65,7 @@ def submit private attr_reader :params, :analytics, :service_provider, :form_response, :uuid_prefix, - :liveness_checking_required + :liveness_checking_required, :acuant_sdk_upgrade_ab_test_bucket def increment_rate_limiter! return unless document_capture_session @@ -366,11 +368,9 @@ def update_analytics(client_response:, vendor_request_time_in_ms:) end def acuant_sdk_upgrade_ab_test_data - return {} unless IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_enabled { - acuant_sdk_upgrade_ab_test_bucket: - AbTests::ACUANT_SDK.bucket(document_capture_session.uuid), - } + acuant_sdk_upgrade_ab_test_bucket:, + }.compact end def acuant_sdk_captured? diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 58b4ea4d279..07edcec7ca6 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -20,6 +20,7 @@ analytics: fake_analytics, liveness_checking_required: liveness_checking_required, doc_auth_vendor: 'mock', + acuant_sdk_upgrade_ab_test_bucket:, ) end @@ -52,6 +53,7 @@ let!(:document_capture_session) { DocumentCaptureSession.create!(user: create(:user)) } let(:document_capture_session_uuid) { document_capture_session.uuid } let(:fake_analytics) { FakeAnalytics.new } + let(:acuant_sdk_upgrade_ab_test_bucket) {} describe '#valid?' do context 'with all valid images' do @@ -324,6 +326,25 @@ expect(response.attention_with_barcode?).to eq(false) end end + + context 'when acuant a/b test is enabled' do + before do + allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_percent).and_return(50) + end + + it 'returns the expected response' do + response = form.submit + + expect(response).to be_a_kind_of DocAuth::Response + expect(response.success?).to eq(true) + expect(response.doc_auth_success?).to eq(true) + expect(response.selfie_status).to eq(:not_processed) + expect(response.errors).to eq({}) + expect(response.attention_with_barcode?).to eq(false) + expect(response.pii_from_doc).to eq(Pii::StateId.new(**Idp::Constants::MOCK_IDV_APPLICANT)) + end + end end context 'image data returns unknown errors' do From 7578083f2ed9d6a9381f45cced834bef3d79150e Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 9 Aug 2024 14:38:48 -0700 Subject: [PATCH 13/16] Remove stray method accidentally added to Idv::Session --- app/services/idv/session.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 06738cde6aa..ef69a96c023 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -290,14 +290,6 @@ def desktop_selfie_test_mode_enabled? IdentityConfig.store.doc_auth_selfie_desktop_test_mode end - def self.ab_test_discriminator - ->(request:, service_provider:, user:, user_session:) { - # If the user is not logged in, we can't include them in any A/B tests - # that rely on Idv::Session to get a discriminator. - return nil if !user || user.is_a?(AnonymousUser) - } - end - private attr_reader :user_session From acb26f95a89291bd003f77a59e92e4927768b805 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 9 Aug 2024 14:41:39 -0700 Subject: [PATCH 14/16] Fix lint issues in api_image_upload_form_spec.rb --- spec/forms/idv/api_image_upload_form_spec.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 07edcec7ca6..4088938f5f7 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -329,8 +329,10 @@ context 'when acuant a/b test is enabled' do before do - allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_enabled).and_return(true) - allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_percent).and_return(50) + allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_enabled). + and_return(true) + allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_percent). + and_return(50) end it 'returns the expected response' do @@ -342,7 +344,9 @@ expect(response.selfie_status).to eq(:not_processed) expect(response.errors).to eq({}) expect(response.attention_with_barcode?).to eq(false) - expect(response.pii_from_doc).to eq(Pii::StateId.new(**Idp::Constants::MOCK_IDV_APPLICANT)) + expect(response.pii_from_doc).to eq( + Pii::StateId.new(**Idp::Constants::MOCK_IDV_APPLICANT), + ) end end end From 2c4f6fdb7dbe290722782613f555311a9f27433b Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Fri, 9 Aug 2024 16:26:58 -0700 Subject: [PATCH 15/16] Remove stray _test_ for method accidentally committed Earlier I was playing with having Idv::Session own discriminator calculation, but I didn't like it. I previously removed a method I accidentally committed--this removes a test for that removed method. --- spec/services/idv/session_spec.rb | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/spec/services/idv/session_spec.rb b/spec/services/idv/session_spec.rb index 500fcc751a7..e7009ef5352 100644 --- a/spec/services/idv/session_spec.rb +++ b/spec/services/idv/session_spec.rb @@ -423,21 +423,4 @@ expect(subject.address_mechanism_chosen?).to eq(false) end end - - describe '#ab_test_discriminator' do - context 'when user is logged in' do - it 'works' do - discriminator = described_class.ab_test_discriminator do |idv_session| - expect(idv_session).not_to be_nil - end - - discriminator.call( - request: nil, - user_session: nil, - user: nil, - service_provider: nil, - ) - end - end - end end From f5f1d180ff175302558c024d855cc6110ae65e10 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Wed, 14 Aug 2024 13:28:54 -0700 Subject: [PATCH 16/16] Add test coverage for A/B test initializers Run intialize tests under different conditions and actually verify they can return buckets --- config/initializers/ab_tests.rb | 16 +- spec/config/initializers/ab_tests_spec.rb | 182 ++++++++++++++++------ 2 files changed, 147 insertions(+), 51 deletions(-) diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index ec69864d442..38b69641eed 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -9,13 +9,19 @@ def self.document_capture_session_uuid_discriminator( user:, user_session: ) - # If we don't have a user, there _may_ be a document capture session UUID - # sitting in session if the user is currently doing hybrid handoff. - return session[:document_capture_session_uuid] if !user + # For users doing hybrid handoff, their document capture session uuid + # will be stored in session. See Idv::HybridMobile::EntryController + if session[:document_capture_session_uuid].present? + return session[:document_capture_session_uuid] + end # Otherwise, try to get the user's current Idv::Session and read # the generated document_capture_session UUID from there - return if !user_session + return if !(user && user_session) + + # Avoid creating a pointless :idv entry in user_session if the + # user has not already started IdV + return unless user_session.key?(:idv) Idv::Session.new( current_user: user, @@ -37,7 +43,7 @@ def self.all IdentityConfig.store.doc_auth_vendor_randomize_percent : 0, }.compact, - ) do |session:, user:, user_session:, **| + ) do |service_provider:, session:, user:, user_session:, **| document_capture_session_uuid_discriminator(service_provider:, session:, user:, user_session:) end.freeze diff --git a/spec/config/initializers/ab_tests_spec.rb b/spec/config/initializers/ab_tests_spec.rb index 78f574de026..b6313e51bd6 100644 --- a/spec/config/initializers/ab_tests_spec.rb +++ b/spec/config/initializers/ab_tests_spec.rb @@ -13,70 +13,160 @@ end end - describe '#document_capture_session_uuid_discriminator' do - let(:request) { spy } - let(:user) { build(:user) } - let(:service_provider) {} - let(:session) { {} } - let(:user_session) { {} } - let(:service_provider) {} - - subject(:discriminator) do - AbTests.document_capture_session_uuid_discriminator( - service_provider:, + shared_examples 'an A/B test that uses document_capture_session_uuid as a discriminator' do + subject(:bucket) do + AbTests.all[ab_test].bucket( + request: nil, + service_provider: nil, session:, user:, user_session:, ) end - context 'when document_capture_session_uuid is present in session' do - let(:session) do - { - document_capture_session_uuid: 'super-random-uuid', - } - end - context 'and user is nil' do - let(:user) {} - it 'returns the uuid in session' do - expect(discriminator).to eql('super-random-uuid') - end + let(:session) { {} } + let(:user) { nil } + let(:user_session) { {} } + + context 'when A/B test is enabled' do + before do + enable_ab_test.call + reload_ab_tests end - context 'and user is not nil' do - it 'does not return the uuid in session' do - expect(discriminator).to be_nil + + context 'and user is logged in' do + let(:user) { build(:user) } + + context 'and document_capture_session_uuid present' do + let(:session) { { document_capture_session_uuid: 'a-random-uuid' } } + + it 'returns a bucket' do + expect(bucket).not_to be_nil + end end - end - end - context 'when document_capture_session_uuid is not present in session' do - context 'when user is nil' do - let(:user) {} - it 'returns nil' do - expect(discriminator).to be_nil + context 'and document_capture_session_uuid not present' do + it 'does not return a bucket' do + expect(bucket).to be_nil + end end - end - context 'when user_session is nil' do - let(:user_session) {} - it 'returns nil' do - expect(discriminator).to be_nil + context 'and the user has a document_capture_session_uuid in their IdV session' do + let(:user_session) do + { + idv: { + document_capture_session_uuid: 'a-random-uuid', + }, + } + end + it 'returns a bucket' do + expect(bucket).not_to be_nil + end + end + context 'and the user does not have an Idv::Session' do + let(:user_session) do + {} + end + it 'does not return a bucket' do + expect(bucket).to be_nil + end + it 'does not write :idv key in user_session' do + expect { bucket }.not_to change { user_session } + end end end - context 'when user_session contains an Idv::Session with a doc capture session uuid' do - let(:user_session) do - { - idv: { - document_capture_session_uuid: 'super-random-uuid', - }, - } + context 'when user is not logged in' do + context 'and document_capture_session_uuid present' do + let(:session) do + { document_capture_session_uuid: 'a-random-uuid' } + end + it 'returns a bucket' do + expect(bucket).not_to be_nil + end end - it 'returns it' do - expect(discriminator).to eql('super-random-uuid') + context 'and document_capture_session_uuid not present' do + it 'does not return a bucket' do + expect(bucket).to be_nil + end end end end + + context 'when A/B test is disabled and it would otherwise assign a bucket' do + let(:user) { build(:user) } + let(:user_session) do + { + idv: { + document_capture_session_uuid: 'a-random-uuid', + }, + } + end + + before do + disable_ab_test.call + reload_ab_tests + end + it 'does not assign a bucket' do + expect(bucket).to be_nil + end + end + end + + describe 'DOC_AUTH_VENDOR' do + let(:ab_test) { :DOC_AUTH_VENDOR } + + let(:enable_ab_test) do + -> { + allow(IdentityConfig.store).to receive(:doc_auth_vendor). + and_return('vendor_a') + allow(IdentityConfig.store).to receive(:doc_auth_vendor_randomize). + and_return(true) + allow(IdentityConfig.store).to receive(:doc_auth_vendor_randomize_alternate_vendor). + and_return('vendor_b') + allow(IdentityConfig.store).to receive(:doc_auth_vendor_randomize_percent). + and_return(50) + } + end + + let(:disable_ab_test) do + -> { + allow(IdentityConfig.store).to receive(:doc_auth_vendor_randomize). + and_return(false) + } + end + + it_behaves_like 'an A/B test that uses document_capture_session_uuid as a discriminator' + end + + describe 'ACUANT_SDK' do + let(:ab_test) { :ACUANT_SDK } + + let(:disable_ab_test) do + -> { + allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_enabled). + and_return(false) + } + end + + let(:enable_ab_test) do + -> { + allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_enabled). + and_return(true) + + allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_percent). + and_return(50) + } + end + + it_behaves_like 'an A/B test that uses document_capture_session_uuid as a discriminator' + end + + def reload_ab_tests + AbTests.all.each do |(name, _)| + AbTests.send(:remove_const, name) + end + load('config/initializers/ab_tests.rb') end end