From 83ec71c3912c259be6dc7f93426df5c81a69edb3 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Wed, 21 Aug 2024 11:21:54 -0700 Subject: [PATCH] A/B testing infrastructure updates (#11026) * Rename AbTestBucket to AbTest AbTests have multiple `buckets`, so this commit renames the class to be a little clearer. * Refactor ab_test_spec.rb - Move tests into #bucket method block - Set up a `let` for bucket configs * 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 * 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) * Add AbTestingConcern - Add new method, ab_test_bucket, for controllers to figure out what bucket the user is in * Update ACUANT_SDK AB test to use new system * Update DOC_AUTH_VENDOR A/B test to use new system * 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. * Limit existing A/B tests to IdV events * 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 * 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. * 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 * Remove stray method accidentally added to Idv::Session * Fix lint issues in api_image_upload_form_spec.rb * 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. * Add test coverage for A/B test initializers Run intialize tests under different conditions and actually verify they can return buckets --- app/controllers/application_controller.rb | 1 + .../concerns/ab_testing_concern.rb | 19 ++ .../concerns/idv/ab_test_analytics_concern.rb | 2 +- .../concerns/idv/acuant_concern.rb | 14 +- .../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 | 4 + app/forms/idv/api_image_upload_form.rb | 14 +- app/services/analytics.rb | 27 +++ app/services/doc_auth_router.rb | 35 ++- app/services/idv/analytics_events_enhancer.rb | 8 +- app/services/idv/proofing_components.rb | 20 +- app/views/idv/document_capture/show.html.erb | 1 + .../document_capture/show.html.erb | 1 + .../idv/shared/_document_capture.html.erb | 2 +- config/initializers/ab_tests.rb | 48 +++- lib/ab_test.rb | 107 +++++++++ lib/ab_test_bucket.rb | 60 ----- spec/config/initializers/ab_tests_spec.rb | 172 +++++++++++++++ .../concerns/ab_testing_concern_spec.rb | 72 ++++++ .../idv/ab_test_analytics_concern_spec.rb | 12 - .../concerns/idv/acuant_concern_spec.rb | 29 ++- spec/features/idv/analytics_spec.rb | 179 ++++++++------- spec/forms/idv/api_image_upload_form_spec.rb | 26 +++ spec/lib/ab_test_bucket_spec.rb | 95 -------- spec/lib/ab_test_spec.rb | 205 ++++++++++++++++++ spec/services/analytics_spec.rb | 50 +++++ spec/services/doc_auth_router_spec.rb | 27 +-- spec/services/idv/proofing_components_spec.rb | 4 + spec/support/fake_ab_test_bucket.rb | 22 -- .../shared/_document_capture.html.erb_spec.rb | 1 + 33 files changed, 916 insertions(+), 363 deletions(-) create mode 100644 app/controllers/concerns/ab_testing_concern.rb create mode 100644 app/controllers/concerns/idv/doc_auth_vendor_concern.rb create mode 100644 lib/ab_test.rb delete mode 100644 lib/ab_test_bucket.rb create mode 100644 spec/config/initializers/ab_tests_spec.rb create mode 100644 spec/controllers/concerns/ab_testing_concern_spec.rb delete mode 100644 spec/lib/ab_test_bucket_spec.rb create mode 100644 spec/lib/ab_test_spec.rb delete mode 100644 spec/support/fake_ab_test_bucket.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/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/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..6f453989985 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,8 @@ def create 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 70228e8d25f..42c35c9c074 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -17,12 +17,16 @@ class ApiImageUploadForm def initialize( params, service_provider:, + doc_auth_vendor:, + acuant_sdk_upgrade_ab_test_bucket:, analytics: nil, uuid_prefix: nil, liveness_checking_required: false ) @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 @@ -61,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 @@ -315,7 +319,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, @@ -364,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/app/services/analytics.rb b/app/services/analytics.rb index a198d653053..8e7c59cf5c4 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(event)) ahoy.track(event, analytics_hash) @@ -71,6 +72,32 @@ def request_attributes attributes.merge!(browser_attributes) end + 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, + 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/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb index 6d91ac9fa57..650c403b95c 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,32 @@ 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:, + session:, + user:, + user_session: + ) + bucket = AbTests::DOC_AUTH_VENDOR.bucket( + request:, + service_provider:, + session:, + 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..ab470910044 100644 --- a/app/services/idv/analytics_events_enhancer.rb +++ b/app/services/idv/analytics_events_enhancer.rb @@ -185,15 +185,19 @@ 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:, idv_session:, + session:, + user:, + user_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 6300bfd8ff3..f5b2a6e64e3 100644 --- a/app/services/idv/proofing_components.rb +++ b/app/services/idv/proofing_components.rb @@ -3,11 +3,15 @@ module Idv class ProofingComponents def initialize( - user:, - idv_session: - ) - @user = user + idv_session:, + session:, + user:, + user_session: + ) @idv_session = idv_session + @session = session + @user = user + @user_session = user_session end def document_check @@ -15,7 +19,11 @@ 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, + session:, + user_session:, + user:, ) end end @@ -65,6 +73,6 @@ def to_h private - attr_reader :user, :idv_session + attr_reader :idv_session, :session, :user, :user_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/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 883a4c1de96..38b69641eed 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -1,23 +1,61 @@ # frozen_string_literal: true -require 'ab_test_bucket' +require 'ab_test' module AbTests - DOC_AUTH_VENDOR = AbTestBucket.new( + def self.document_capture_session_uuid_discriminator( + service_provider:, + session:, + user:, + user_session: + ) + # 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 && 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, + service_provider:, + user_session:, + ).document_capture_session_uuid + 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', + should_log: /^idv/i, buckets: { alternate_vendor: IdentityConfig.store.doc_auth_vendor_randomize ? IdentityConfig.store.doc_auth_vendor_randomize_percent : 0, }.compact, - ).freeze + ) do |service_provider:, session:, user:, user_session:, **| + document_capture_session_uuid_discriminator(service_provider:, session:, user:, user_session:) + end.freeze - ACUANT_SDK = AbTestBucket.new( + 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 : 0, }, - ).freeze + ) 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 new file mode 100644 index 00000000000..ef090aabe43 --- /dev/null +++ b/lib/ab_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +class AbTest + 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. + # @yieldparam [User] user + # @yieldparam [Hash] user_session + def initialize( + experiment_name:, + buckets: {}, + should_log: nil, + default_bucket: :default, + &discriminator + ) + @buckets = buckets + @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? + end + + # @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:, session:, 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) + + min = 0 + buckets.keys.each do |key| + max = min + buckets[key] + return key if user_value > min && user_value <= max + min = max + end + + @default_bucket + end + + def include_in_analytics_event?(event_name) + if should_log.is_a?(Regexp) + should_log.match?(event_name) + elsif !should_log.nil? + raise 'Unexpected value used for should_log' + else + true + end + end + + 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 + + def valid_bucket_data_structure? + return false if !buckets.is_a?(Hash) + + buckets.values.each { |v| Float(v) } + + true + rescue ArgumentError + false + end + + def ensure_numeric_percentages + buckets.keys.each do |key| + buckets[key] = buckets[key].to_f if buckets[key].is_a?(String) + end + end + + def within_100_percent? + valid_bucket_data_structure? && buckets.values.sum <= 100 + end +end diff --git a/lib/ab_test_bucket.rb b/lib/ab_test_bucket.rb deleted file mode 100644 index 5819dd63f9b..00000000000 --- a/lib/ab_test_bucket.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -class AbTestBucket - attr_reader :buckets, :experiment_name, :default_bucket - - MAX_SHA = (16 ** 64) - 1 - - def initialize(experiment_name:, buckets: {}, default_bucket: :default) - @buckets = buckets - @experiment_name = experiment_name - @default_bucket = default_bucket - raise 'invalid bucket data structure' unless valid_bucket_data_structure? - ensure_numeric_percentages - raise 'bucket percentages exceed 100' unless within_100_percent? - end - - def bucket(discriminator = nil) - return @default_bucket if discriminator.blank? - - user_value = percent(discriminator) - - min = 0 - buckets.keys.each do |key| - max = min + buckets[key] - return key if user_value > min && user_value <= max - min = max - end - - @default_bucket - end - - private - - def percent(discriminator) - Digest::SHA256.hexdigest("#{discriminator}:#{experiment_name}").to_i(16).to_f / MAX_SHA * 100 - 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 - - hash_bucket && simple_values - end - - def ensure_numeric_percentages - buckets.keys.each do |key| - buckets[key] = buckets[key].to_f if buckets[key].is_a?(String) - end - end - - def within_100_percent? - valid_bucket_data_structure? && buckets.values.sum <= 100 - end -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..b6313e51bd6 --- /dev/null +++ b/spec/config/initializers/ab_tests_spec.rb @@ -0,0 +1,172 @@ +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 + + 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 + + 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 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 + + context 'and document_capture_session_uuid not present' do + it 'does not return a bucket' do + expect(bucket).to be_nil + end + end + + 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 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 + + 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 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 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 cb0523dfbea..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', - FakeAbTestBucket.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', - FakeAbTestBucket.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', - FakeAbTestBucket.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/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 7b3ee6da010..4088938f5f7 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -19,6 +19,8 @@ service_provider: build(:service_provider, issuer: 'test_issuer'), analytics: fake_analytics, liveness_checking_required: liveness_checking_required, + doc_auth_vendor: 'mock', + acuant_sdk_upgrade_ab_test_bucket:, ) end @@ -51,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 @@ -323,6 +326,29 @@ 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 diff --git a/spec/lib/ab_test_bucket_spec.rb b/spec/lib/ab_test_bucket_spec.rb deleted file mode 100644 index e74940880c6..00000000000 --- a/spec/lib/ab_test_bucket_spec.rb +++ /dev/null @@ -1,95 +0,0 @@ -require 'rails_helper' - -RSpec.describe AbTestBucket 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( - 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 - end - - 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 }) - 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 - - expect(results[:default]).to be_nil - end - end - - context 'configured with no buckets' do - let(:subject) { AbTestBucket.new(experiment_name: 'test') } - - it 'returns :default' do - bucket = subject.bucket(SecureRandom.uuid) - - expect(bucket).to eq :default - end - end - - context 'configured with buckets with string percentages' do - let(:subject) { AbTestBucket.new(experiment_name: 'test', 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 - - context 'configured with buckets with random strings' do - let(:subject) { AbTestBucket.new(experiment_name: 'test', buckets: { foo: 'foo', bar: 'bar' }) } - - it 'converts string to zero percent and returns :default' do - bucket = subject.bucket(SecureRandom.uuid) - - expect(bucket).to eq :default - end - 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 }) } - - it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'bucket percentages exceed 100') - end - end - - context 'misconfigured with buckets in the wrong data structure' do - let(:subject) { AbTestBucket.new(experiment_name: 'test', buckets: [[:foo, 10], [:bar, 20]]) } - - it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') - end - end -end diff --git a/spec/lib/ab_test_spec.rb b/spec/lib/ab_test_spec.rb new file mode 100644 index 00000000000..7226db1b171 --- /dev/null +++ b/spec/lib/ab_test_spec.rb @@ -0,0 +1,205 @@ +require 'rails_helper' + +RSpec.describe AbTest do + subject do + AbTest.new( + experiment_name: 'test', + buckets:, + should_log:, + &discriminator + ) + end + + let(:discriminator) do + ->(**) { SecureRandom.uuid } + end + + let(:buckets) do + { foo: 20, bar: 30, baz: 50 } + end + + let(:should_log) do + nil + 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 + 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 + + 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 + + it 'returns nil for bucket' do + expect(bucket).to be_nil + end + end + + context 'configured with no buckets' do + let(:buckets) { {} } + + 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 + + context 'configured with buckets with string percentages' do + let(:buckets) { { foo: '100' } } + + it 'converts string percentages to numbers and returns the correct result' do + expect(bucket).to eq :foo + end + end + + context 'configured with buckets with random strings' do + let(:buckets) { { foo: 'foo', bar: 'bar' } } + + it 'raises a RuntimeError' do + expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') + end + end + + 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') + end + end + + 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') + 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 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 true is used' do + let(:should_log) { true } + it 'raises' do + expect { return_value }.to raise_error + end + end + + context 'when false is used' do + let(:should_log) { false } + it 'raises' do + expect { return_value }.to raise_error + end + end + end +end diff --git a/spec/services/analytics_spec.rb b/spec/services/analytics_spec.rb index 5daebdc5789..07b0dd5dc02 100644 --- a/spec/services/analytics_spec.rb +++ b/spec/services/analytics_spec.rb @@ -143,6 +143,56 @@ ) 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, + }, + should_log:, + ) do |user:, **| + user.id + end, + } + end + + let(:should_log) {} + + 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 + + context 'when should_log says not to' do + let(:should_log) { /some other event/ } + 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 it 'tracks session duration' do 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..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,7 +21,9 @@ subject do described_class.new( + session:, user:, + user_session:, idv_session:, ) end diff --git a/spec/support/fake_ab_test_bucket.rb b/spec/support/fake_ab_test_bucket.rb deleted file mode 100644 index 612fb63f498..00000000000 --- a/spec/support/fake_ab_test_bucket.rb +++ /dev/null @@ -1,22 +0,0 @@ -# Mock version of AbTestBucket, used to pre-assign items to buckets for deterministic tests -class FakeAbTestBucket - 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 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