diff --git a/Gemfile b/Gemfile index cae60fd679..e011f86a46 100644 --- a/Gemfile +++ b/Gemfile @@ -155,7 +155,8 @@ gem 'will_paginate' gem 'chronic' # Salesforce -gem 'openstax_salesforce' +gem 'restforce' +gem 'openstax_active_force' # Allows 'ap' alternative to 'pp', used in a mailer gem 'awesome_print' diff --git a/Gemfile.lock b/Gemfile.lock index a8ba590718..3779f9bb22 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -518,10 +518,6 @@ GEM rails (>= 3.0) openstax_rescue_from (4.3.0) rails (>= 3.1, < 7.0) - openstax_salesforce (8.3.0) - openstax_active_force - rails (>= 5.0, < 7.0) - restforce openstax_transaction_isolation (2.0.0) activerecord (>= 6) openstax_transaction_retry (2.0.0) @@ -876,11 +872,11 @@ DEPENDENCIES omniauth-google-oauth2 omniauth-identity omniauth-twitter + openstax_active_force openstax_api openstax_healthcheck openstax_path_prefixer! openstax_rescue_from - openstax_salesforce openstax_transaction_isolation openstax_transaction_retry openstax_utilities @@ -903,6 +899,7 @@ DEPENDENCIES recaptcha redis-rails representable + restforce rexml rspec-instafail rspec-rails diff --git a/app/assets/javascripts/newflow/newflow_ui.js.coffee b/app/assets/javascripts/newflow/newflow_ui.js.coffee index 9a0b9352cf..421b655947 100644 --- a/app/assets/javascripts/newflow/newflow_ui.js.coffee +++ b/app/assets/javascripts/newflow/newflow_ui.js.coffee @@ -31,12 +31,14 @@ NewflowUi = do () -> @disableButton(targetSelector) enableOnChecked: (targetSelector, sourceSelector) -> + # Run synchronously inside $(document).ready: set the initial disabled + # state from the checkbox, then attach the click handler. The previous + # version deferred the initial check by 500ms via setTimeout, which + # raced with feature specs that `check`-ed the box before the timer + # fired (the click handler is attached, but the deferred check then + # disabled the button right after the spec saw it as enabled). $(document).ready => - - enable_disable_continue = () => - this.checkCheckedButton(targetSelector, sourceSelector) - - setTimeout(enable_disable_continue, 500) + this.checkCheckedButton(targetSelector, sourceSelector) $(sourceSelector).on 'click', => this.checkCheckedButton(targetSelector, sourceSelector) diff --git a/app/controllers/admin/salesforce_drift_findings_controller.rb b/app/controllers/admin/salesforce_drift_findings_controller.rb new file mode 100644 index 0000000000..59a4ff87fe --- /dev/null +++ b/app/controllers/admin/salesforce_drift_findings_controller.rb @@ -0,0 +1,17 @@ +module Admin + class SalesforceDriftFindingsController < BaseController + def index + scope = SalesforceDriftFinding.open.includes(:user).order(last_seen_at: :desc) + scope = scope.where(category: params[:category]) if params[:category].present? + scope = scope.where(user_id: params[:user_id]) if params[:user_id].present? + @findings = scope.limit(500) + @categories = SalesforceDriftFinding.open.distinct.pluck(:category).sort + end + + def update + finding = SalesforceDriftFinding.find(params[:id]) + finding.resolve! + redirect_to admin_salesforce_drift_findings_path, notice: 'Finding marked resolved.' + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index fba9e5e0ae..efa3e6e291 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -12,6 +12,18 @@ def js_search def index; end + def edit + # SecurityLog#event_type is an integer-backed Rails enum, so we can't LIKE on it. + # Resolve the salesforce_* event_type *values* once and filter by IN. + salesforce_event_values = SecurityLog.event_types + .select { |name, _| name.to_s.start_with?('salesforce_') } + .values + @salesforce_timeline = SecurityLog + .where(user_id: @user.id, event_type: salesforce_event_values) + .order(created_at: :asc) + .limit(500) + end + # Used by full console page def search security_log :users_searched_by_admin, search: params[:search] @@ -108,7 +120,7 @@ def change_salesforce_contact end begin - contact = OpenStax::Salesforce::Remote::Contact.find(new_id) + contact = Salesforce::Records::Contact.find(new_id) if contact.present? # The contact really exists, so save its ID to the User diff --git a/app/models/salesforce_drift_finding.rb b/app/models/salesforce_drift_finding.rb new file mode 100644 index 0000000000..75c8ab662d --- /dev/null +++ b/app/models/salesforce_drift_finding.rb @@ -0,0 +1,43 @@ +class SalesforceDriftFinding < ApplicationRecord + belongs_to :user, optional: true + + validates :category, presence: true + validates :first_seen_at, presence: true + validates :last_seen_at, presence: true + + scope :open, -> { where(resolved_at: nil) } + scope :resolved, -> { where.not(resolved_at: nil) } + scope :for_category, ->(c) { where(category: c) } + + # Upsert an open finding: if a matching open finding already exists, bump + # its last_seen_at (and details, when provided); otherwise create one. + def self.upsert_finding!(category:, user: nil, record_type: nil, record_id: nil, details: {}) + existing = open.find_by( + user_id: user&.id, + category: category, + salesforce_record_type: record_type, + salesforce_record_id: record_id + ) + + if existing + attrs = { last_seen_at: Time.current } + attrs[:details] = details if details.present? + existing.update!(attrs) + existing + else + create!( + user: user, + category: category, + salesforce_record_type: record_type, + salesforce_record_id: record_id, + details: details, + first_seen_at: Time.current, + last_seen_at: Time.current + ) + end + end + + def resolve! + update!(resolved_at: Time.current) + end +end diff --git a/app/models/security_log.rb b/app/models/security_log.rb index a90127dc0a..27343a3750 100644 --- a/app/models/security_log.rb +++ b/app/models/security_log.rb @@ -113,6 +113,29 @@ class SecurityLog < ApplicationRecord user_already_has_contact_not_creating_lead creating_new_salesforce_lead salesforce_lead_save_failed + salesforce_lookup_started + salesforce_lookup_matched_by_stored_id + salesforce_lookup_matched_by_uuid + salesforce_lookup_matched_by_email + salesforce_lookup_stored_id_disowned + salesforce_lookup_email_collision + salesforce_upsert_lead_begin + salesforce_upsert_lead_saved + salesforce_upsert_lead_save_failed + salesforce_upsert_lead_skipped_user_has_contact + salesforce_lead_id_persist_retry + salesforce_lead_id_persist_failed + salesforce_stale_contact_id_cleared + salesforce_contact_skipped_merged_or_deleted + salesforce_contact_id_swapped + salesforce_contact_id_conflict + salesforce_user_school_not_cached + salesforce_reconcile_user_ok + salesforce_reconcile_contact_id_cleared + salesforce_reconcile_followed_merge + salesforce_reconcile_attached_from_conversion + salesforce_link_restored_by_reconcile + salesforce_contact_id_orphaned ] json_serialize :event_data, Hash diff --git a/app/models/user.rb b/app/models/user.rb index a0c5a13199..668b0d0ff0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -162,11 +162,11 @@ class User < ApplicationRecord attribute :is_not_gdpr_location, :boolean, default: nil def lead - OpenStax::Salesforce::Remote::Lead.find_by(email: best_email_address_for_salesforce) + Salesforce::Records::Lead.find_by(email: best_email_address_for_salesforce) end def contact - OpenStax::Salesforce::Remote::Contact.find(salesforce_contact_id) + Salesforce::Records::Contact.find(salesforce_contact_id) end def most_accurate_school_name diff --git a/app/routines/newflow/create_or_update_salesforce_lead.rb b/app/routines/newflow/create_or_update_salesforce_lead.rb index a276a2c3bc..b38115345d 100644 --- a/app/routines/newflow/create_or_update_salesforce_lead.rb +++ b/app/routines/newflow/create_or_update_salesforce_lead.rb @@ -1,259 +1,20 @@ module Newflow + # Thin shim around Salesforce::UpsertLead. Kept so existing callers + # (EducatorSignup::CompleteProfile, EducatorSignup::SheeridWebhook, + # Admin::UsersController#force_update_lead) don't have to change. + # See docs/superpowers/specs/2026-05-20-salesforce-sync-design.md. class CreateOrUpdateSalesforceLead - lev_routine active_job_enqueue_options: { queue: :salesforce } - LEAD_SOURCE = 'Account Creation' - DEFAULT_REFERRING_APP_NAME = 'Accounts' - - ADOPTION_STATUS_FROM_USER = { - as_primary: 'Confirmed Adoption Won', - as_recommending: 'Confirmed Will Recommend', - as_future: 'High Interest in Adopting' - }.with_indifferent_access.freeze - - private_constant(:ADOPTION_STATUS_FROM_USER) - - protected ################# + protected def exec(user:) return unless user - status.set_job_name(self.class.name) status.set_job_args(user: user.to_global_id.to_s) - SecurityLog.create!( - user: user, - event_type: :starting_salesforce_lead_creation - ) - - sf_school_id = user.school&.salesforce_id - # no school attached to user? Set to Find Me A Home - unless sf_school_id - fallback_school = OpenStax::Salesforce::Remote::School.find_by(name: 'Find Me A Home') - raise "Salesforce 'Find Me A Home' school not found — cannot assign fallback school for user #{user.id}" unless fallback_school - - sf_school_id = fallback_school.id - user.school = School.find_by(salesforce_id: sf_school_id) - end - - if user.role == 'student' - sf_role = 'Student' - else - sf_role = 'Instructor' - sf_position = user.role - end - - # as_future means they are interested, not adopting, so no adoptionJSON for them - if user.using_openstax_how != 'as_future' - adoption_json = build_book_adoption_json_for_salesforce(user) - end - - # Check the state of the SheerID response and profile completion to determine faculty status for lead - sheerid_response = SheeridVerification.find_by(verification_id: user.sheerid_verification_id) - if user.is_profile_complete? - user.faculty_status = :pending_faculty - unless sheerid_response.nil? - user.faculty_status = sheerid_response.current_step_to_faculty_status - end - else - # User has not completed their profile - user.faculty_status = :incomplete_signup - end - user.save! - - lead = nil - if user.salesforce_lead_id - begin - lead = OpenStax::Salesforce::Remote::Lead.find(user.salesforce_lead_id) - rescue StandardError => e - # Log when the stored lead ID doesn't correspond to an existing lead or find fails - SecurityLog.create!( - user: user, - event_type: :salesforce_lead_not_found_by_id, - event_data: { - salesforce_lead_id: user.salesforce_lead_id, - error: e.class.name, - error_message: e.message - } - ) - Sentry.capture_message( - "Salesforce lead ID #{user.salesforce_lead_id} not found for user #{user.id}, will search by UUID and email. Error: #{e.class.name}: #{e.message}" - ) - end - end - - # If no lead found by stored ID, search for existing lead by UUID - if lead.nil? - lead = OpenStax::Salesforce::Remote::Lead.find_by(accounts_uuid: user.uuid) - if lead - SecurityLog.create!( - user: user, - event_type: :salesforce_lead_found_by_uuid, - event_data: { lead_id: lead.id } - ) - end - end - - # If still no lead found, search by email - if lead.nil? - lead = OpenStax::Salesforce::Remote::Lead.find_by(email: user.best_email_address_for_salesforce) - if lead - SecurityLog.create!( - user: user, - event_type: :salesforce_lead_found_by_email, - event_data: { lead_id: lead.id, email: user.best_email_address_for_salesforce } - ) - end - end - - # If user has a contact (already converted from lead), don't create a new lead - if lead.nil? && user.salesforce_contact_id.present? - begin - contact = OpenStax::Salesforce::Remote::Contact.find(user.salesforce_contact_id) - if contact - SecurityLog.create!( - user: user, - event_type: :user_already_has_contact_not_creating_lead, - event_data: { contact_id: user.salesforce_contact_id } - ) - # User already has a contact, return without creating a lead - outputs.lead = nil - outputs.user = user - return - end - rescue StandardError => e - # Contact not found, proceed with lead creation - Sentry.capture_message( - "Salesforce contact ID #{user.salesforce_contact_id} not found for user #{user.id}, will create lead. Error: #{e.class.name}: #{e.message}" - ) - end - end - - # Only create a new lead if none exists - if lead.nil? - lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) - SecurityLog.create!( - user: user, - event_type: :creating_new_salesforce_lead, - event_data: { email: user.best_email_address_for_salesforce, uuid: user.uuid } - ) - end - - lead.first_name = user.first_name - lead.last_name = user.last_name - lead.phone = user.phone_number - lead.source = LEAD_SOURCE - lead.application_source = DEFAULT_REFERRING_APP_NAME - lead.role = sf_role - lead.position = sf_position - lead.title = user.other_role_name - lead.who_chooses_books = user.who_chooses_books - lead.subject_interest = user.which_books - lead.num_students = user.how_many_students - lead.adoption_status = ADOPTION_STATUS_FROM_USER[user.using_openstax_how] - lead.expected_start_semester = expected_start_semester_label_for(user.expected_start_semester) - lead.adoption_json = adoption_json - lead.os_accounts_id = user.id - lead.accounts_uuid = user.uuid - lead.school = user.most_accurate_school_name - lead.city = user.most_accurate_school_city - lead.country = user.most_accurate_school_country - lead.verification_status = user.faculty_status == User::NO_FACULTY_INFO ? nil : user.faculty_status - lead.b_r_i_marketing = user.is_b_r_i_user? - lead.title_1_school = user.title_1_school? - lead.newsletter = user.receive_newsletter? - lead.newsletter_opt_in = user.receive_newsletter? - lead.self_reported_school = user.self_reported_school - lead.sheerid_school_name = user.sheerid_reported_school - lead.account_id = sf_school_id - lead.school_id = sf_school_id - lead.signup_date = user.created_at.strftime("%Y-%m-%dT%T.%L%z") - lead.tracking_parameters = "#{Rails.application.secrets.openstax_url}/accounts/i/signup/" - - state = user.most_accurate_school_state - unless state.blank? - state = nil unless US_STATES.map(&:downcase).include? state.downcase - end - unless state.nil? - # Figure out if the State is an abbreviation or the full name - if state == state.upcase - lead.state_code = state - else - lead.state = state - end - end - - SecurityLog.create!( - user: user, - event_type: :attempting_to_create_user_lead, - event_data: { lead_data: lead } - ) - - if lead.save - user.salesforce_lead_id = lead.id - if user.save - SecurityLog.create!( - user: user, - event_type: :created_salesforce_lead, - event_data: { lead_id: lead.id.to_s } - ) - else - SecurityLog.create!( - user: user, - event_type: :educator_sign_up_failed, - event_data: { lead_id: lead.id, user_errors: user.errors.full_messages } - ) - Sentry.capture_message("User #{user.id} was not successfully saved with lead #{lead.id}: #{user.errors.full_messages.join(', ')}") - end - else - if lead.errors&.full_messages.to_s.include?('INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY') - Sentry.capture_message("Invalid school (#{user.school&.salesforce_id}) for user (#{user.id})") - end - SecurityLog.create!( - user: user, - event_type: :salesforce_lead_save_failed, - event_data: { lead_errors: lead.errors&.full_messages, email: user.best_email_address_for_salesforce } - ) - Sentry.capture_message("Salesforce lead save failed for user #{user.id}: #{lead.errors&.full_messages&.join(', ')}") - end - - outputs.lead = lead + Salesforce::UpsertLead.call(user: user) outputs.user = user end - - def expected_start_semester_label_for(key) - return nil if key.blank? - I18n.t(:'educator_profile_form.expected_start_semester_options')[key.to_sym] - end - - def build_book_adoption_json_for_salesforce(user) - adoption_json = {} - books_json = [] - return nil unless user.books_used_details - - user.books_used_details.each do |book| - book_value = book[0] - if book_value.match(/\[.*\]/) - book_name = book_value.gsub(/\[.*\]/, '').strip # Calculus Volume 1 - book_language = book_value[/\[(.*?)\]/, 1] # Spanish (no brackets) - books_json << { - name: book_name, - students: book[1]["num_students_using_book"], - howUsing: book[1]["how_using_book"], - language: book_language, - } - else - books_json << { - name: book_value, - students: book[1]["num_students_using_book"], - howUsing: book[1]["how_using_book"] - } - end - end - - adoption_json['Books'] = books_json - adoption_json.to_json - end end end diff --git a/app/routines/update_salesforce_assignable_fields.rb b/app/routines/update_salesforce_assignable_fields.rb index 3261560fe1..482d8e5eda 100644 --- a/app/routines/update_salesforce_assignable_fields.rb +++ b/app/routines/update_salesforce_assignable_fields.rb @@ -16,7 +16,7 @@ def call(created_after) contact_id = external_id.user.salesforce_contact_id next if contact_id.nil? - contact = OpenStax::Salesforce::Remote::Contact.find(contact_id) + contact = Salesforce::Records::Contact.find(contact_id) next if contact.nil? contact.assignable_interest = 'Fully Integrated' diff --git a/app/routines/update_school_salesforce_info.rb b/app/routines/update_school_salesforce_info.rb index b7ef626ed6..eeaee4dd18 100644 --- a/app/routines/update_school_salesforce_info.rb +++ b/app/routines/update_school_salesforce_info.rb @@ -1,87 +1,11 @@ +# Thin shim around Salesforce::SyncSchools. Kept so cron entries and any +# rake/console callers continue to work without changes. class UpdateSchoolSalesforceInfo - BATCH_SIZE = 250 - - SF_TO_DB_CACHE_COLUMNS_MAP = { - id: :salesforce_id, - name: :name, - city: :city, - state: :state, - country: :country, - type: :type, - school_location: :location, - is_kip: :is_kip, - is_child_of_kip: :is_child_of_kip, - sheerid_school_name: :sheerid_school_name, - has_assignable_contacts: :has_assignable_contacts - } + # Re-export constants for existing specs/code that referenced them. + BATCH_SIZE = Salesforce::SyncSchools::BATCH_SIZE + SF_TO_DB_CACHE_COLUMNS_MAP = Salesforce::SyncSchools::SF_TO_DB_CACHE_COLUMNS_MAP def self.call - new.call - end - - def log(message, level = :info) - Rails.logger.tagged(self.class.name) { Rails.logger.public_send level, message } - end - - def call - check_in_id = Sentry.capture_check_in('update-school-salesforce', :in_progress) - log('Starting UpdateSchoolSalesforceInfo') - - # Check if any Schools that have 0 users have been deleted from Salesforce and remove them - School.where( - 'NOT EXISTS (SELECT * FROM "users" WHERE "users"."school_id" = "schools"."id")' - ).find_in_batches(batch_size: BATCH_SIZE) do |schools| - salesforce_ids = schools.map(&:salesforce_id) - - existing_salesforce_ids = OpenStax::Salesforce::Remote::School.select(:id).where( - id: salesforce_ids - ).map(&:id) - - deleted_salesforce_ids = salesforce_ids - existing_salesforce_ids - - School.where(salesforce_id: deleted_salesforce_ids).delete_all - end - - # Go through all SF Schools and cache their information, if it changed - schools_updated = 0 - last_id = nil - loop do - sf_schools = OpenStax::Salesforce::Remote::School.order(:Id).limit(BATCH_SIZE) - - sf_schools = sf_schools.where("Id > '#{last_id}'") unless last_id.nil? - sf_schools = sf_schools.to_a - last_id = sf_schools.last&.id - - begin - schools_by_sf_id = School.where( - salesforce_id: sf_schools.map(&:id) - ).index_by(&:salesforce_id) - - schools = sf_schools.map do |sf_school| - school = schools_by_sf_id[sf_school.id] - schools_updated += 1 if school.nil? - school = School.new(salesforce_id: sf_school.id) if school.nil? - - SF_TO_DB_CACHE_COLUMNS_MAP.each do |sf_column, db_column| - school.public_send "#{db_column}=", sf_school.public_send(sf_column) - end - - school.changed? ? school : nil - end.compact - - School.import( - schools, validate: false, on_duplicate_key_update: { - conflict_target: [ :salesforce_id ], columns: SF_TO_DB_CACHE_COLUMNS_MAP.values - } - ) unless schools.empty? - rescue StandardError => se - Sentry.capture_exception se - end - - break if sf_schools.length < BATCH_SIZE - end - - log("Finished updating #{schools_updated} schools") - Sentry.capture_check_in('update-school-salesforce', :ok, check_in_id: check_in_id) + Salesforce::SyncSchools.call end end diff --git a/app/routines/update_user_contact_info.rb b/app/routines/update_user_contact_info.rb index 79b3b4d01f..7621a63cd3 100644 --- a/app/routines/update_user_contact_info.rb +++ b/app/routines/update_user_contact_info.rb @@ -1,177 +1,12 @@ +# Thin shim around Salesforce::SyncContacts. Kept so cron entries and any +# rake/console callers continue to work without changes. +# See docs/superpowers/specs/2026-05-20-salesforce-sync-design.md. class UpdateUserContactInfo - class UnknownFacultyVerifiedError < StandardError; end - - COLLEGE_TYPES = [ - 'College/University (4)', - 'Technical/Community College (2)', - 'Career School/For-Profit (2)' - ] - HIGH_SCHOOL_TYPES = [ 'High School' ] - K12_TYPES = [ 'K-12 School' ] - HOME_SCHOOL_TYPES = [ 'Home School' ] - - DOMESTIC_SCHOOL_LOCATIONS = [ 'Domestic' ] - FOREIGN_SCHOOL_LOCATIONS = [ 'Foreign' ] - - ADOPTION_STATUSES = { - "Current Adopter" => true, - "Future Adopter" => true, - "Past Adopter" => true, - "Not Adopter" => false - } - def self.call - new.call + Salesforce::SyncContacts.call end - def call - check_in_id = Sentry.capture_check_in('update-user-contact-info', :in_progress) - log("Starting sync with Salesforce") - contacts = salesforce_contacts - log("#{contacts.count} contacts fetched from Salesforce") - contacts_by_uuid = contacts_by_uuid_hash(contacts) - - users ||= User.where(uuid: contacts.map(&:accounts_uuid)) - schools_by_salesforce_id = School.select(:id, :salesforce_id).where( - salesforce_id: contacts_by_uuid.values.compact.map(&:school_id) - ).index_by(&:salesforce_id) - - - # loop through users - we keep some counts for logging out - users_updated = 0 - users_fv_status_changed = 0 - users_without_cached_school = 0 - log("Updating #{users.count} users from Salesforce") - - users.each do |user| - sf_contact = contacts_by_uuid[user.uuid] - school = schools_by_salesforce_id[sf_contact.school_id] - - previous_contact_id = user.salesforce_contact_id - user.salesforce_contact_id = sf_contact.id - - if sf_contact.id != previous_contact_id - SecurityLog.create!( - user: user, - event_type: :user_contact_id_updated_from_salesforce, - event_data: { previous_contact_id: previous_contact_id, new_contact_id: sf_contact.id } - ) - end - - old_fv_status = user.faculty_status - # Map Salesforce faculty_verified values to our string-based enum values - # nil maps to no_faculty_info; unknown values raise an error - faculty_verified = sf_contact.faculty_verified - new_status = if faculty_verified.nil? - "no_faculty_info" - elsif User::VALID_FACULTY_STATUSES.include?(faculty_verified) - faculty_verified - else - message = "Unknown faculty_verified field: '#{faculty_verified}' on contact #{sf_contact.id}" - Sentry.capture_message(message) - raise UnknownFacultyVerifiedError, message - end - - # Don't overwrite confirmed or pending faculty status with incomplete/no_info - # Don't overwrite confirmed with pending - # Don't overwrite rejected_faculty with incomplete/no_info - should_update_status = true - if user.faculty_status == "confirmed_faculty" && - ["pending_faculty", "incomplete_signup", "no_faculty_info"].include?(new_status) - should_update_status = false - elsif user.faculty_status == "pending_faculty" && - ["incomplete_signup", "no_faculty_info"].include?(new_status) - should_update_status = false - elsif user.faculty_status == "rejected_faculty" && - ["incomplete_signup", "no_faculty_info"].include?(new_status) - should_update_status = false - end - - user.faculty_status = new_status if should_update_status - - if user.faculty_status_changed? - users_fv_status_changed += 1 - SecurityLog.create!( - user: user, - event_type: :salesforce_updated_faculty_status, - event_data: { user_id: user.id, salesforce_contact_id: sf_contact.id, old_status: old_fv_status, new_status: user.faculty_status } - ) - end - - user.school_type = case sf_contact.school_type - when *COLLEGE_TYPES - :college - when *HIGH_SCHOOL_TYPES - :high_school - when *K12_TYPES - :k12_school - when *HOME_SCHOOL_TYPES - :home_school - when NilClass - :unknown_school_type - else - :other_school_type - end - - sf_school = sf_contact.school - user.school_location = case sf_school&.school_location - when *DOMESTIC_SCHOOL_LOCATIONS - :domestic_school - when *FOREIGN_SCHOOL_LOCATIONS - :foreign_school - else - :unknown_school_location - end - - # TODO: This can be removed once OSWeb is migated to using the new adopter_status field for renewal forms - unless sf_contact.adoption_status.blank? - user.using_openstax = ADOPTION_STATUSES[sf_contact.adoption_status] - end - - user.adopter_status = sf_contact.adoption_status - user.is_kip = sf_school&.is_kip || sf_school&.is_child_of_kip - - if school.nil? && !sf_school.nil? - users_without_cached_school += 1 - Sentry.capture_message("User #{user.id} has a school that is in SF but not cached yet #{sf_school.id}") - else - user.school = school - end - - user.save! && users_updated += 1 if user.changed? - end - log("Completed updating #{users_updated} users.") - log("#{users_fv_status_changed} users had their faculty status updated.") - log("#{users_without_cached_school} users had no cached school in accounts. This should update on the next sync (after UpdateSchoolSalesforceInfo runs) or it is missing in Salesforce.") - Sentry.capture_check_in('update-user-contact-info', :ok, check_in_id: check_in_id) - end - - def salesforce_contacts - contact_days = Settings::Db.store.number_of_days_contacts_modified ||= 1 - c_date = contact_days.to_i.day.ago.strftime("%Y-%m-%d") - contacts ||= OpenStax::Salesforce::Remote::Contact.select( - :id, - :email, - :faculty_verified, - :school_type, - :adoption_status, - :accounts_uuid - ) - .where("Accounts_UUID__c != null") - .where("LastModifiedDate >= #{DateTime.strptime(c_date,"%Y-%m-%d").utc.iso8601}") - .includes(:school) - .to_a - end - - def contacts_by_uuid_hash(contacts) - contacts_by_uuid = {} - contacts.each do |contact| - contacts_by_uuid[contact.accounts_uuid] = contact - end - contacts_by_uuid - end - - def log(message, level = :info) - Rails.logger.tagged(self.class.name) { Rails.logger.public_send level, message } - end + # Backwards compatibility for tests/callers that referenced the old class's + # exception. + UnknownFacultyVerifiedError = Salesforce::ResolveFacultyStatus::UnknownFacultyVerifiedError end diff --git a/app/routines/update_user_lead_info.rb b/app/routines/update_user_lead_info.rb deleted file mode 100644 index b0f4e7b33a..0000000000 --- a/app/routines/update_user_lead_info.rb +++ /dev/null @@ -1,75 +0,0 @@ -class UpdateUserLeadInfo - - def self.call - new.call - end - def call - # TODO: we do want to limit this, but we need to update all the leads and schedule this first - # we are only using this to check users created in the last month - # start_date = Time.zone.now - 1.day - # end_date = Time.zone.now - 30.day - # for the query below when this is re-added - # .where("created_at <= ? AND created_at >= ?", start_date, end_date) - - users = User.where(salesforce_contact_id: nil) - .where.not(salesforce_lead_id: nil, role: :student, faculty_status: :rejected_faculty) - - leads = OpenStax::Salesforce::Remote::Lead.select(:id, :accounts_uuid, :verification_status) - .where(accounts_uuid: users.map(&:uuid)) - .to_a - .index_by(&:accounts_uuid) - - users.map do |user| - lead = leads[user.uuid] - - unless lead.nil? - previous_lead_id = user.salesforce_lead_id - user.salesforce_lead_id = lead.id # it might change in SF lead merging - - if lead.id != previous_lead_id - SecurityLog.create!( - user: user, - event_type: :user_lead_id_updated_from_salesforce, - event_data: { previous_lead_id: previous_lead_id, new_lead_id: lead.id } - ) - end - - old_fv_status = user.faculty_status - user.faculty_status = case lead.verification_status - when "confirmed_faculty" - :confirmed_faculty - when "pending_faculty" - :pending_faculty - when "rejected_faculty" - :rejected_faculty - when "rejected_by_sheerid" - :rejected_by_sheerid - when "incomplete_signup" - :incomplete_signup - when "no_faculty_info" - :no_faculty_info - when NilClass - :no_faculty_info - else - Sentry.capture_message("Unknown faculty_verified field: '#{ - lead.verification_status}'' on lead #{lead.id}") - end - - if user.faculty_status_changed? - SecurityLog.create!( - user: user, - event_type: :salesforce_updated_faculty_status, - event_data: { - user_id: user.id, - salesforce_lead_id: lead.id, - old_status: old_fv_status, - new_status: user.faculty_status - } - ) - end - - user.save if user.changed? - end - end - end -end diff --git a/app/services/salesforce.rb b/app/services/salesforce.rb new file mode 100644 index 0000000000..ed80a322e3 --- /dev/null +++ b/app/services/salesforce.rb @@ -0,0 +1,36 @@ +module Salesforce + class IllegalState < StandardError; end + + def self.configure + yield configuration + end + + def self.configuration + @configuration ||= Configuration.new + end + + def self.reset_configuration! + @configuration = nil + end + + class Configuration + attr_writer :api_version, :login_domain + attr_accessor :username, :password, :security_token, :consumer_key, :consumer_secret + + def api_version + @api_version ||= '66.0' + end + + def login_domain + @login_domain ||= 'test.salesforce.com' + end + + def validate! + raise IllegalState, 'The Salesforce username is missing' if username.nil? + raise IllegalState, 'The Salesforce password is missing' if password.nil? + raise IllegalState, 'The Salesforce security token is missing' if security_token.nil? + raise IllegalState, 'The Salesforce consumer key is missing' if consumer_key.nil? + raise IllegalState, 'The Salesforce consumer secret is missing' if consumer_secret.nil? + end + end +end diff --git a/app/services/salesforce/audit.rb b/app/services/salesforce/audit.rb new file mode 100644 index 0000000000..e2ccae98fa --- /dev/null +++ b/app/services/salesforce/audit.rb @@ -0,0 +1,13 @@ +module Salesforce + module Audit + EVENT_PREFIX = 'salesforce_'.freeze + + def self.record(user, event_name, **details) + full = "#{EVENT_PREFIX}#{event_name}" + unless SecurityLog.event_types.key?(full) + raise ArgumentError, "Unknown Salesforce audit event: #{full.inspect}. Add it to SecurityLog#event_type." + end + SecurityLog.create!(user: user, event_type: full, event_data: details) + end + end +end diff --git a/app/services/salesforce/build_lead.rb b/app/services/salesforce/build_lead.rb new file mode 100644 index 0000000000..af73a716fe --- /dev/null +++ b/app/services/salesforce/build_lead.rb @@ -0,0 +1,107 @@ +module Salesforce + # Pure mapping from a `User` to a `Salesforce::Records::Lead`'s attributes. + # Side-effect-free (does not save). Always sets `accounts_uuid` so retries + # of `UpsertLead` can find a just-created Lead via the UUID branch of + # `Salesforce::Lookup`, keeping job retries idempotent. + module BuildLead + LEAD_SOURCE = 'Account Creation'.freeze + DEFAULT_REFERRING_APP_NAME = 'Accounts'.freeze + + ADOPTION_STATUS_FROM_USER = { + 'as_primary' => 'Confirmed Adoption Won', + 'as_recommending' => 'Confirmed Will Recommend', + 'as_future' => 'High Interest in Adopting' + }.freeze + + module_function + + def apply(lead, user) + sf_school_id = user.school&.salesforce_id + + if user.role == 'student' + sf_role = 'Student' + sf_position = nil + else + sf_role = 'Instructor' + sf_position = user.role + end + + adoption_json = user.using_openstax_how == 'as_future' ? nil : build_adoption_json(user) + + lead.first_name = user.first_name + lead.last_name = user.last_name + lead.phone = user.phone_number + lead.source = LEAD_SOURCE + lead.application_source = DEFAULT_REFERRING_APP_NAME + lead.role = sf_role + lead.position = sf_position + lead.title = user.other_role_name + lead.who_chooses_books = user.who_chooses_books + lead.subject_interest = user.which_books + lead.num_students = user.how_many_students + lead.adoption_status = ADOPTION_STATUS_FROM_USER[user.using_openstax_how] + lead.expected_start_semester = expected_start_semester_label_for(user.expected_start_semester) + lead.adoption_json = adoption_json + lead.os_accounts_id = user.id + lead.accounts_uuid = user.uuid + lead.school = user.most_accurate_school_name + lead.city = user.most_accurate_school_city + lead.country = user.most_accurate_school_country + lead.verification_status = user.faculty_status == User::NO_FACULTY_INFO ? nil : user.faculty_status + lead.b_r_i_marketing = user.is_b_r_i_user? + lead.title_1_school = user.title_1_school? + lead.newsletter = user.receive_newsletter? + lead.newsletter_opt_in = user.receive_newsletter? + lead.self_reported_school = user.self_reported_school + lead.sheerid_school_name = user.sheerid_reported_school + lead.account_id = sf_school_id + lead.school_id = sf_school_id + lead.signup_date = user.created_at.strftime('%Y-%m-%dT%T.%L%z') + lead.tracking_parameters = "#{Rails.application.secrets.openstax_url}/accounts/i/signup/" + + assign_state(lead, user.most_accurate_school_state) + + lead + end + + def assign_state(lead, raw_state) + return if raw_state.blank? + state = raw_state + state = nil unless US_STATES.map(&:downcase).include?(state.downcase) + return if state.nil? + if state == state.upcase + lead.state_code = state + else + lead.state = state + end + end + + def expected_start_semester_label_for(key) + return nil if key.blank? + I18n.t(:'educator_profile_form.expected_start_semester_options')[key.to_sym] + end + + def build_adoption_json(user) + return nil unless user.books_used_details + + books_json = user.books_used_details.map do |book_value, details| + if book_value.match(/\[.*\]/) + { + name: book_value.gsub(/\[.*\]/, '').strip, + students: details['num_students_using_book'], + howUsing: details['how_using_book'], + language: book_value[/\[(.*?)\]/, 1] + } + else + { + name: book_value, + students: details['num_students_using_book'], + howUsing: details['how_using_book'] + } + end + end + + { 'Books' => books_json }.to_json + end + end +end diff --git a/app/services/salesforce/client.rb b/app/services/salesforce/client.rb new file mode 100644 index 0000000000..a0420fd360 --- /dev/null +++ b/app/services/salesforce/client.rb @@ -0,0 +1,20 @@ +require 'restforce' + +module Salesforce + class Client < ::Restforce::Data::Client + def initialize + configuration = Salesforce.configuration + configuration.validate! + + super( + username: configuration.username, + password: configuration.password, + security_token: configuration.security_token, + client_id: configuration.consumer_key, + client_secret: configuration.consumer_secret, + api_version: configuration.api_version, + host: configuration.login_domain + ) + end + end +end diff --git a/app/services/salesforce/lookup.rb b/app/services/salesforce/lookup.rb new file mode 100644 index 0000000000..2a97e3755e --- /dev/null +++ b/app/services/salesforce/lookup.rb @@ -0,0 +1,78 @@ +module Salesforce + module Lookup + Result = Struct.new(:lead, :contact, :matched_by, :rejected, keyword_init: true) do + def initialize(**kwargs) + kwargs[:rejected] ||= [] + super(**kwargs) + end + end + + module_function + + # Resolve a Salesforce::Records::Lead for a user. Tries the stored + # salesforce_lead_id first (best), then accounts_uuid, then email (with a + # UUID-collision guard). Returns a Result; result.lead may be nil. + def lead_for(user) + result = Result.new + + if user.salesforce_lead_id.present? + lead = safe_find(Salesforce::Records::Lead, user.salesforce_lead_id) + if lead.nil? + result.rejected << :stored_id_not_found + elsif Verify.lead_owns_user?(lead, user) + result.lead = lead + result.matched_by = :stored_id + Audit.record(user, :lookup_matched_by_stored_id, lead_id: lead.id) + return result + else + result.rejected << :stored_id_owned_by_other_user + Audit.record(user, :lookup_stored_id_disowned, lead_id: lead.id) + end + end + + lead = Salesforce::Records::Lead.find_by({ accounts_uuid: user.uuid }) + if lead + result.lead = lead + result.matched_by = :uuid + Audit.record(user, :lookup_matched_by_uuid, lead_id: lead.id) + return result + end + + email = user.best_email_address_for_salesforce + if email.present? + lead = Salesforce::Records::Lead.find_by({ email: email }) + if lead && Verify.lead_owns_user?(lead, user) + result.lead = lead + result.matched_by = :email + Audit.record(user, :lookup_matched_by_email, lead_id: lead.id, email: email) + return result + elsif lead + result.rejected << :email_match_uuid_conflict + Audit.record(user, :lookup_email_collision, lead_id: lead.id, email: email) + end + end + + result + end + + # Resolve a verified Contact for a user, or nil. Stored + # salesforce_contact_id is the primary signal; falls back to UUID. + def contact_for(user) + if user.salesforce_contact_id.present? + contact = safe_find(Salesforce::Records::Contact, user.salesforce_contact_id) + return contact if Verify.contact_owns_user?(contact, user) + end + + return nil if user.uuid.blank? + + candidate = Salesforce::Records::Contact.find_by({ accounts_uuid: user.uuid }) + Verify.contact_owns_user?(candidate, user) ? candidate : nil + end + + def safe_find(klass, id) + klass.find(id) + rescue StandardError + nil + end + end +end diff --git a/app/services/salesforce/metrics.rb b/app/services/salesforce/metrics.rb new file mode 100644 index 0000000000..44dfc513a0 --- /dev/null +++ b/app/services/salesforce/metrics.rb @@ -0,0 +1,63 @@ +module Salesforce + # Per-run counter bag for the Salesforce sync routines. + # + # Routines instantiate one Metrics, increment counters during the run, and + # call #emit at the end. emit writes a Sentry check-in (when a slug is + # configured) plus a single JSON log line tagged with the run name. #alert! + # produces a tagged Sentry message for threshold-based alerts so existing + # tag-based alert rules can subscribe to the `salesforce-alert` tag. + class Metrics + attr_reader :counters, :run, :slug, :started_at + + def initialize(run:, slug: nil) + @run = run + @slug = slug + @counters = {} + @started_at = Time.current + @check_in_id = nil + end + + # Mark a Sentry check-in as in_progress so the eventual #emit can close it. + def start! + return unless slug + @check_in_id = Sentry.capture_check_in(slug, :in_progress) + end + + # Increment a counter. With no labels, stores an integer. + # With keyword labels, stores a hash with :total plus a per-label tally. + def increment(key, by: 1, **labels) + if labels.empty? + current = @counters[key] + @counters[key] = (current.is_a?(Integer) ? current : 0) + by + else + existing = @counters[key].is_a?(Hash) ? @counters[key] : { total: 0 } + existing[:total] = (existing[:total] || 0) + by + labels.each_value do |label_value| + existing[label_value] = (existing[label_value] || 0) + by + end + @counters[key] = existing + end + end + + def emit(status: :ok, extra: {}) + payload = { + run: run, + status: status, + duration_s: (Time.current - started_at).to_i, + counters: counters, + **extra + } + Rails.logger.tagged('salesforce', run) { Rails.logger.info(payload.to_json) } + Sentry.capture_check_in(slug, status, check_in_id: @check_in_id) if slug + payload + end + + def alert!(name, value:, threshold:) + Sentry.capture_message( + "Salesforce alert: #{name} (value=#{value}, threshold=#{threshold})", + tags: { 'salesforce-alert' => name.to_s }, + extra: { value: value, threshold: threshold, run: run } + ) + end + end +end diff --git a/app/services/salesforce/reconcile.rb b/app/services/salesforce/reconcile.rb new file mode 100644 index 0000000000..d7f1710e90 --- /dev/null +++ b/app/services/salesforce/reconcile.rb @@ -0,0 +1,317 @@ +module Salesforce + # Nightly two-way drift detection + Accounts-side self-heal. + # + # Pass 1: anchor on users with stored salesforce_contact_id; verify the + # SF Contact is alive and owns this user. Heal merges/deletes/ + # disowned records; open drift findings for everything we can't + # resolve on the Accounts side. + # Pass 2: anchor on users with stored salesforce_lead_id (no contact). + # Attach a Contact when the Lead has been converted; clear and + # re-resolve when the Lead is missing or disowned. + # Pass 3: discover missing links for profile-complete instructors with + # no stored ids, by looking them up in SF by accounts_uuid. + # SF-orphan sweep: find SF Leads/Contacts whose accounts_uuid we don't + # recognize (last 90 days of LastModifiedDate); open findings. + # Finalize: close findings not refreshed during this run; prune ancient + # resolved findings; fire the drift_findings_total_open alert. + # + # Self-heal writes are gated by Settings::FeatureFlags + # .salesforce_reconcile_self_heal (default false on first deploy). + class Reconcile + SLUG = 'reconcile-salesforce'.freeze + BATCH_SIZE = 500 + SWEEP_LOOKBACK = 90.days + + def self.call + new.call + end + + def initialize + @metrics = Metrics.new(run: 'reconcile', slug: SLUG) + @self_heal = Settings::FeatureFlags.salesforce_reconcile_self_heal + @queries = 0 + @max_queries = Settings::Salesforce.reconcile_max_queries + end + + def call + @metrics.start! + log "Reconcile starting (self_heal=#{@self_heal})" + run_pass_1 + run_pass_2 + run_pass_3 + sweep_sf_orphans + finalize_findings + @metrics.emit(status: :ok) + rescue StandardError => e + Sentry.capture_exception(e) + @metrics.emit(status: :error, extra: { error: e.class.name }) + raise + end + + # ----- Pass 1: contact-anchored ----- # + + def run_pass_1 + User.where.not(salesforce_contact_id: nil).find_in_batches(batch_size: BATCH_SIZE) do |users| + break if budget_exceeded? + contact_ids = users.map(&:salesforce_contact_id).uniq + contacts = fetch_contacts_by_id(contact_ids) + users.each { |u| reconcile_user_by_stored_contact(u, contacts[u.salesforce_contact_id]) } + @metrics.increment(:users_pass_1, by: users.size) + end + end + + def fetch_contacts_by_id(ids) + return {} if ids.empty? + @queries += 1 + Salesforce::Records::Contact + .select(:id, :accounts_uuid, :master_record_id, :is_deleted) + .where(id: ids).index_by(&:id) + end + + def reconcile_user_by_stored_contact(user, sf_contact) + if sf_contact.nil? + finding(user, 'sf_contact_uuid_mismatch', 'Contact', user.salesforce_contact_id, details: { reason: 'missing_in_sf' }) + heal_clear_contact_id(user) + return + end + + if sf_contact.is_deleted + finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { reason: 'is_deleted' }) + heal_clear_contact_id(user) + return + end + + if sf_contact.master_record_id.present? + master = safe_find(Salesforce::Records::Contact, sf_contact.master_record_id) + if master && Verify.contact_owns_user?(master, user) + heal_swap_contact_id(user, master.id, reason: :merged) + else + finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { reason: 'merged_no_owner_match' }) + heal_clear_contact_id(user) + end + return + end + + if sf_contact.accounts_uuid.blank? + finding(user, 'sf_contact_missing_uuid', 'Contact', sf_contact.id) + return # SF-side problem; do not mutate Accounts + end + + if sf_contact.accounts_uuid != user.uuid + finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { uuid_in_sf: sf_contact.accounts_uuid }) + heal_reattach_via_lookup(user) + return + end + + Audit.record(user, :reconcile_user_ok, contact_id: sf_contact.id) + close_findings(user, category: 'sf_contact_uuid_mismatch') + end + + def heal_clear_contact_id(user) + return unless @self_heal + prior = user.salesforce_contact_id + user.update!(salesforce_contact_id: nil) + Audit.record(user, :reconcile_contact_id_cleared, prior: prior) + @metrics.increment(:contact_clears) + heal_reattach_via_lookup(user) + end + + def heal_swap_contact_id(user, new_id, reason:) + return unless @self_heal + from = user.salesforce_contact_id + user.update!(salesforce_contact_id: new_id) + Audit.record(user, :reconcile_followed_merge, from: from, to: new_id, reason: reason) + @metrics.increment(:contact_swaps_by_reconcile, reason: reason) + end + + def heal_reattach_via_lookup(user) + return unless @self_heal + contact = Lookup.contact_for(user) + if contact + user.update!(salesforce_contact_id: contact.id) + Audit.record(user, :link_restored_by_reconcile, contact_id: contact.id) + @metrics.increment(:links_restored) + else + prior = user.salesforce_contact_id_was || user.salesforce_contact_id + Audit.record(user, :contact_id_orphaned, prior_contact_id: prior) + @metrics.increment(:contacts_orphaned) + end + end + + # ----- Pass 2: lead-anchored ----- # + + def run_pass_2 + User.where(salesforce_contact_id: nil).where.not(salesforce_lead_id: nil) + .find_in_batches(batch_size: BATCH_SIZE) do |users| + break if budget_exceeded? + lead_ids = users.map(&:salesforce_lead_id).uniq + leads = fetch_leads_by_id(lead_ids) + users.each { |u| reconcile_user_by_stored_lead(u, leads[u.salesforce_lead_id]) } + @metrics.increment(:users_pass_2, by: users.size) + end + end + + def fetch_leads_by_id(ids) + return {} if ids.empty? + @queries += 1 + Salesforce::Records::Lead + .select(:id, :accounts_uuid, :is_converted, :converted_contact_id) + .where(id: ids).index_by(&:id) + end + + def reconcile_user_by_stored_lead(user, lead) + if lead.nil? + finding(user, 'sf_lead_uuid_mismatch', 'Lead', user.salesforce_lead_id, details: { reason: 'missing_in_sf' }) + heal_reattach_via_lookup(user) if @self_heal + return + end + + if lead.is_converted && lead.converted_contact_id.present? + contact = safe_find(Salesforce::Records::Contact, lead.converted_contact_id) + if contact && Verify.contact_owns_user?(contact, user) + if @self_heal + user.update!(salesforce_contact_id: contact.id) + Audit.record(user, :reconcile_attached_from_conversion, lead_id: lead.id, contact_id: contact.id) + @metrics.increment(:contacts_attached_from_lead_conversion) + end + return + end + end + + if lead.accounts_uuid != user.uuid + finding(user, 'sf_lead_uuid_mismatch', 'Lead', lead.id, details: { uuid_in_sf: lead.accounts_uuid }) + heal_reattach_via_lookup(user) if @self_heal + return + end + + Audit.record(user, :reconcile_user_ok, lead_id: lead.id) + end + + # ----- Pass 3: missing-link discovery ----- # + + def run_pass_3 + scope = User.where(salesforce_contact_id: nil, salesforce_lead_id: nil) + .where(is_profile_complete: true) + .where(role: User.roles[:instructor]) + .where.not(faculty_status: User.faculty_statuses[:rejected_faculty]) + + scope.find_in_batches(batch_size: BATCH_SIZE) do |users| + break if budget_exceeded? + uuids = users.map(&:uuid) + @queries += 2 + contacts_by_uuid = Salesforce::Records::Contact.where(accounts_uuid: uuids).index_by(&:accounts_uuid) + leads_by_uuid = Salesforce::Records::Lead.where(accounts_uuid: uuids).index_by(&:accounts_uuid) + + users.each { |u| attach_missing_link(u, contacts_by_uuid[u.uuid], leads_by_uuid[u.uuid]) } + @metrics.increment(:users_pass_3, by: users.size) + end + end + + def attach_missing_link(user, contact, lead) + if contact && Verify.contact_owns_user?(contact, user) + if @self_heal + user.update!(salesforce_contact_id: contact.id) + Audit.record(user, :link_restored_by_reconcile, contact_id: contact.id, via: :pass_3) + @metrics.increment(:links_restored) + end + return + end + + if lead && Verify.lead_owns_user?(lead, user) && !lead.is_converted + if @self_heal + user.update!(salesforce_lead_id: lead.id) + Audit.record(user, :link_restored_by_reconcile, lead_id: lead.id, via: :pass_3) + @metrics.increment(:links_restored) + end + return + end + + finding(user, 'user_unlinked_eligible', nil, nil) + @metrics.increment(:unlinked_eligible) + end + + # ----- SF-orphan sweep ----- # + + def sweep_sf_orphans + return if budget_exceeded? + + since = SWEEP_LOOKBACK.ago.utc.iso8601 + @queries += 2 + + contact_uuids = pluck_accounts_uuids(Salesforce::Records::Contact, since) + lead_uuids = pluck_accounts_uuids(Salesforce::Records::Lead, since) + + all_uuids = (contact_uuids + lead_uuids).uniq + known = User.where(uuid: all_uuids).pluck(:uuid).to_set + + (contact_uuids.uniq - known.to_a).each do |uuid| + finding(nil, 'sf_orphan_contact', 'Contact', nil, details: { accounts_uuid: uuid }) + end + (lead_uuids.uniq - known.to_a).each do |uuid| + finding(nil, 'sf_orphan_lead', 'Lead', nil, details: { accounts_uuid: uuid }) + end + end + + def pluck_accounts_uuids(klass, since) + klass.select(:id, :accounts_uuid) + .where("Accounts_UUID__c != null AND LastModifiedDate >= #{since}") + .pluck(:accounts_uuid) + .compact + rescue StandardError => e + Sentry.capture_exception(e) + [] + end + + # ----- Finalize ----- # + + def finalize_findings + cutoff = @metrics.started_at + closed = SalesforceDriftFinding.open.where('last_seen_at < ?', cutoff).update_all(resolved_at: Time.current) + @metrics.increment(:findings_closed, by: closed) + + SalesforceDriftFinding.resolved.where('resolved_at < ?', 60.days.ago).delete_all + + total_open = SalesforceDriftFinding.open.count + @metrics.increment(:findings_total_open, by: total_open) if total_open > 0 + + threshold = Settings::Salesforce.alert_drift_open_total + if total_open > threshold + @metrics.alert!(:drift_findings_total_open, value: total_open, threshold: threshold) + end + end + + # ----- Helpers ----- # + + def finding(user, category, type, id, details: {}) + SalesforceDriftFinding.upsert_finding!( + category: category, user: user, + record_type: type, record_id: id, details: details + ) + @metrics.increment(:findings_opened, category: category) + end + + def close_findings(user, category:) + SalesforceDriftFinding.open.where(user: user, category: category) + .update_all(resolved_at: Time.current) + end + + def budget_exceeded? + if @queries >= @max_queries + @metrics.alert!(:reconcile_budget_exceeded, value: @queries, threshold: @max_queries) + true + else + false + end + end + + def safe_find(klass, id) + klass.find(id) + rescue StandardError + nil + end + + def log(msg) + Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) } + end + end +end diff --git a/app/services/salesforce/records/base.rb b/app/services/salesforce/records/base.rb new file mode 100644 index 0000000000..071764c03f --- /dev/null +++ b/app/services/salesforce/records/base.rb @@ -0,0 +1,47 @@ +require 'active_force' + +# Reopen ActiveForce::SObject (provided by the openstax_active_force gem) so +# our records inherit `find_or_initialize_by` and `save_if_changed` without an +# intermediate subclass. An intermediate `Salesforce::Records::Base < SObject` +# fights SObject's `inherited` hook, which auto-adds `field :id, from: 'Id'` +# to every subclass and would then double-register :id on grandchild records. +class ActiveForce::SObject + def self.find_or_initialize_by(conditions) + find_by(conditions) || new(conditions) + end + + def save_if_changed + save if changed? + end +end + +module Salesforce + module Records + # Records::Base is the type new code should reference. It IS + # ActiveForce::SObject under the hood, with the helpers above. + Base = ActiveForce::SObject + end +end + +# Make ActiveForce build a Salesforce::Client lazily — only on first use, not +# during Rails boot. This keeps migrations and console boot safe even when +# Salesforce secrets aren't configured (e.g. in CI or dev). +module ActiveForce + class << self + unless singleton_class.method_defined?(:_original_sfdc_client) || + singleton_class.private_method_defined?(:_original_sfdc_client) + alias_method :_original_sfdc_client, :sfdc_client + end + + def sfdc_client + unless _original_sfdc_client.is_a?(::Salesforce::Client) + self.sfdc_client = ::Salesforce::Client.new + end + _original_sfdc_client + end + + def clear_sfdc_client! + self.sfdc_client = nil + end + end +end diff --git a/app/services/salesforce/records/contact.rb b/app/services/salesforce/records/contact.rb new file mode 100644 index 0000000000..0784f7f870 --- /dev/null +++ b/app/services/salesforce/records/contact.rb @@ -0,0 +1,28 @@ +module Salesforce + module Records + class Contact < Base + self.table_name = 'Contact' + + belongs_to :school, foreign_key: :school_id, model: Salesforce::Records::School + + field :id, from: 'Id' + field :name, from: 'Name' + field :first_name, from: 'FirstName' + field :last_name, from: 'LastName' + field :email, from: 'Email' + field :faculty_verified, from: 'FV_Status__c' + field :last_modified_at, from: 'LastModifiedDate' + field :school_id, from: 'AccountId' + field :school_type, from: 'School_Type__c' + field :all_emails, from: 'All_Emails__c' + field :adoption_status, from: 'Adoption_Status__c' + field :accounts_uuid, from: 'Accounts_UUID__c' + field :lead_source, from: 'LeadSource' + field :signup_date, from: 'Signup_Date__c', as: :datetime + field :assignable_interest, from: 'Assignable_Interest__c' + field :assignable_adoption_date, from: 'Assignable_Adoption_Date__c', as: :datetime + field :master_record_id, from: 'MasterRecordId' + field :is_deleted, from: 'IsDeleted', as: :boolean + end + end +end diff --git a/app/services/salesforce/records/lead.rb b/app/services/salesforce/records/lead.rb new file mode 100644 index 0000000000..2b95809165 --- /dev/null +++ b/app/services/salesforce/records/lead.rb @@ -0,0 +1,50 @@ +module Salesforce + module Records + class Lead < Base + self.table_name = 'Lead' + + field :id, from: 'Id' + field :name, from: 'Name' + field :first_name, from: 'FirstName' + field :last_name, from: 'LastName' + field :salutation, from: 'Salutation' + field :title, from: 'Title' + field :subject, from: 'Subject__c' + field :subject_interest, from: 'Subject_Interest__c' + field :school, from: 'Company' + field :city, from: 'City' + field :state, from: 'State' + field :state_code, from: 'StateCode' + field :country, from: 'Country' + field :phone, from: 'Phone' + field :website, from: 'Website' + field :status, from: 'Status' + field :email, from: 'Email' + field :source, from: 'LeadSource' + field :newsletter, from: 'Newsletter__c' + field :newsletter_opt_in, from: 'Newsletter_Opt_In__c' + field :adoption_status, from: 'Adoption_Status__c' + field :adoption_json, from: 'AdoptionsJSON__c' + field :num_students, from: 'Number_of_Students__c' + field :os_accounts_id, from: 'Accounts_ID__c' + field :accounts_uuid, from: 'Accounts_UUID__c' + field :application_source, from: 'Application_Source__c' + field :role, from: 'Role__c' + field :position, from: 'Position__c' + field :who_chooses_books, from: 'who_chooses_books__c' + field :verification_status, from: 'FV_Status__c' + field :b_r_i_marketing, from: 'BRI_Marketing__c', as: :boolean + field :title_1_school, from: 'Title_1_school__c', as: :boolean + field :sheerid_school_name, from: 'SheerID_School_Name__c' + field :instant_conversion, from: 'Instant_Conversion__c', as: :boolean + field :signup_date, from: 'Signup_Date__c', as: :datetime + field :self_reported_school, from: 'Self_Reported_School__c' + field :tracking_parameters, from: 'Tracking_Parameters__c' + field :expected_start_semester, from: 'Expected_Start_Semester__c' + field :account_id, from: 'Account_ID__c' + field :school_id, from: 'School__c' + field :is_converted, from: 'IsConverted', as: :boolean + field :converted_contact_id, from: 'ConvertedContactId' + end + end +end diff --git a/app/services/salesforce/records/school.rb b/app/services/salesforce/records/school.rb new file mode 100644 index 0000000000..13de64a450 --- /dev/null +++ b/app/services/salesforce/records/school.rb @@ -0,0 +1,20 @@ +module Salesforce + module Records + class School < Base + self.table_name = 'Account' + + field :id, from: 'Id' + field :name, from: 'Name' + field :city, from: 'BillingCity' + field :state, from: 'BillingState' + field :country, from: 'BillingCountry' + field :type, from: 'Type' + field :school_location, from: 'School_Location__c' + field :sheerid_school_name, from: 'SheerID_School_Name__c' + field :is_kip, from: 'K_I_P__c', as: :boolean + field :is_child_of_kip, from: 'child_of_kip__c', as: :boolean + field :total_school_enrollment, from: 'Total_School_Enrollment__c', as: :integer + field :has_assignable_contacts, from: 'Has_Assignable_Contacts__c', as: :boolean + end + end +end diff --git a/app/services/salesforce/resolve_faculty_status.rb b/app/services/salesforce/resolve_faculty_status.rb new file mode 100644 index 0000000000..3f7b2a71c5 --- /dev/null +++ b/app/services/salesforce/resolve_faculty_status.rb @@ -0,0 +1,62 @@ +module Salesforce + # Determines a user's faculty_status from signup context or from a Salesforce + # Contact, preserving the protection rules that prevent confirmed/pending/ + # rejected statuses from being downgraded to incomplete/no_info. + module ResolveFacultyStatus + class UnknownFacultyVerifiedError < StandardError; end + + # Statuses that should not be overwritten by an incoming :incomplete_signup + # or :no_faculty_info from Salesforce. + PROTECTED_BY_INCOMPLETE_OR_NO_INFO = %w[confirmed_faculty pending_faculty rejected_faculty].freeze + + # An incoming :pending_faculty should not overwrite an existing + # :confirmed_faculty. + PROTECTED_BY_PENDING = %w[confirmed_faculty].freeze + + DOWNGRADE_VALUES = %w[incomplete_signup no_faculty_info].freeze + + module_function + + # Set faculty_status based on signup state (profile completion + SheerID). + # Persists the user. + def from_signup(user) + if user.is_profile_complete? + new_status = :pending_faculty + verification = SheeridVerification.find_by(verification_id: user.sheerid_verification_id) + new_status = verification.current_step_to_faculty_status if verification + user.faculty_status = new_status + else + user.faculty_status = :incomplete_signup + end + user.save! + end + + # Apply faculty_verified from an SF Contact to user, respecting the + # protection rules. Does NOT persist (caller decides). + def from_contact(user, sf_contact) + faculty_verified = sf_contact.faculty_verified + new_status = + if faculty_verified.nil? + 'no_faculty_info' + elsif User::VALID_FACULTY_STATUSES.include?(faculty_verified) + faculty_verified + else + msg = "Unknown faculty_verified field: '#{faculty_verified}' on contact #{sf_contact.id}" + Sentry.capture_message(msg) + raise UnknownFacultyVerifiedError, msg + end + + return if blocked?(user.faculty_status, new_status) + + user.faculty_status = new_status + end + + def blocked?(current, incoming) + current = current.to_s + incoming = incoming.to_s + return true if PROTECTED_BY_INCOMPLETE_OR_NO_INFO.include?(current) && DOWNGRADE_VALUES.include?(incoming) + return true if PROTECTED_BY_PENDING.include?(current) && incoming == 'pending_faculty' + false + end + end +end diff --git a/app/services/salesforce/sync_contacts.rb b/app/services/salesforce/sync_contacts.rb new file mode 100644 index 0000000000..2745f62764 --- /dev/null +++ b/app/services/salesforce/sync_contacts.rb @@ -0,0 +1,215 @@ +module Salesforce + # Every-30-min incremental sync of Salesforce Contacts into Accounts users. + # Replaces UpdateUserContactInfo's body. Cursor-driven (with overlap), + # skips merged/deleted contacts at fetch time, gates every + # salesforce_contact_id swap on Verify.contact_can_be_replaced? evidence. + class SyncContacts + SLUG = 'update-user-contact-info'.freeze + + COLLEGE_TYPES = ['College/University (4)', 'Technical/Community College (2)', 'Career School/For-Profit (2)'].freeze + HIGH_SCHOOL_TYPES = ['High School'].freeze + K12_TYPES = ['K-12 School'].freeze + HOME_SCHOOL_TYPES = ['Home School'].freeze + + DOMESTIC_SCHOOL_LOCATIONS = ['Domestic'].freeze + FOREIGN_SCHOOL_LOCATIONS = ['Foreign'].freeze + + ADOPTION_STATUSES = { + 'Current Adopter' => true, + 'Future Adopter' => true, + 'Past Adopter' => true, + 'Not Adopter' => false + }.freeze + + def self.call + new.call + end + + def call + @metrics = Metrics.new(run: 'sync_contacts', slug: SLUG) + @metrics.start! + log 'Starting Salesforce contact sync' + + window_start = compute_window_start + log "Window start: #{window_start.iso8601}" + check_cron_drift(window_start) + + run_started_at = Time.current + contacts = fetch_contacts(window_start) + @metrics.increment(:contacts_fetched, by: contacts.size) + log "#{contacts.size} contacts fetched" + + uuids = contacts.map(&:accounts_uuid).compact.uniq + users_by_uuid = User.where(uuid: uuids).index_by(&:uuid) + schools_by_sf_id = School + .where(salesforce_id: contacts.map { |c| c.school&.id }.compact) + .index_by(&:salesforce_id) + + @verify_cache = {} + + contacts.each { |sf_contact| process_contact(sf_contact, users_by_uuid, schools_by_sf_id) } + + Settings::Salesforce.contacts_synced_through = run_started_at + emit_threshold_alerts + @metrics.emit(status: :ok) + rescue StandardError => e + Sentry.capture_exception(e) + @metrics.emit(status: :error, extra: { error: e.class.name, message: e.message }) + raise + end + + private + + def compute_window_start + cursor = Settings::Salesforce.contacts_synced_through || + Settings::Db.store.number_of_days_contacts_modified.to_i.days.ago + cursor - Settings::Salesforce.contacts_lookback_overlap_hours.hours + end + + def check_cron_drift(window_start) + age_hours = ((Time.current - window_start) / 3600.0).to_i + threshold = Settings::Salesforce.alert_cron_drift_hours + @metrics.alert!(:cron_drift, value: age_hours, threshold: threshold) if age_hours > threshold * 2 + end + + def fetch_contacts(since) + Salesforce::Records::Contact.select( + :id, :email, :faculty_verified, :school_type, :adoption_status, + :accounts_uuid, :master_record_id, :is_deleted + ).where("Accounts_UUID__c != null") + .where("LastModifiedDate >= #{since.utc.iso8601}") + .includes(:school) + .to_a + end + + def process_contact(sf_contact, users_by_uuid, schools_by_sf_id) + user = users_by_uuid[sf_contact.accounts_uuid] + unless user + @metrics.increment(:unknown_accounts_uuids) + return + end + + if sf_contact.master_record_id.present? || sf_contact.is_deleted + @metrics.increment(:contacts_skipped_merged_or_deleted) + Audit.record(user, :contact_skipped_merged_or_deleted, + contact_id: sf_contact.id, + master_record_id: sf_contact.master_record_id, + is_deleted: sf_contact.is_deleted) + return + end + + apply(user, sf_contact, schools_by_sf_id) + @metrics.increment(:users_matched) + end + + def apply(user, sf_contact, schools_by_sf_id) + previous = user.salesforce_contact_id + + if previous.blank? + user.salesforce_contact_id = sf_contact.id + elsif previous != sf_contact.id + return unless safe_to_swap?(user, sf_contact, previous) + end + + apply_faculty_status(user, sf_contact) + apply_school_and_type(user, sf_contact, schools_by_sf_id) + apply_adoption_status(user, sf_contact) + + if user.changed? + user.save! + @metrics.increment(:users_updated) + end + end + + def safe_to_swap?(user, sf_contact, previous) + reason = @verify_cache[previous] ||= Verify.contact_can_be_replaced?( + previous_id: previous, by: sf_contact, user: user + ) + case reason + when :gone, :merged, :uuid_cleared + Audit.record(user, :contact_id_swapped, from: previous, to: sf_contact.id, reason: reason) + @metrics.increment(:contact_id_swaps, reason: reason) + user.salesforce_contact_id = sf_contact.id + true + else + Audit.record(user, :contact_id_conflict, stored: previous, candidate: sf_contact.id) + @metrics.increment(:contact_id_conflicts) + Sentry.capture_message( + "Salesforce contact conflict for user #{user.id}: stored=#{previous} candidate=#{sf_contact.id}" + ) + false + end + end + + def apply_faculty_status(user, sf_contact) + old_status = user.faculty_status + ResolveFacultyStatus.from_contact(user, sf_contact) + return unless user.faculty_status_changed? + @metrics.increment(:users_fv_status_changed) + Audit.record(user, :updated_faculty_status, + user_id: user.id, salesforce_contact_id: sf_contact.id, + old_status: old_status, new_status: user.faculty_status) + end + + def apply_school_and_type(user, sf_contact, schools_by_sf_id) + user.school_type = case sf_contact.school_type + when *COLLEGE_TYPES then :college + when *HIGH_SCHOOL_TYPES then :high_school + when *K12_TYPES then :k12_school + when *HOME_SCHOOL_TYPES then :home_school + when NilClass then :unknown_school_type + else :other_school_type + end + + sf_school = sf_contact.school + user.school_location = case sf_school&.school_location + when *DOMESTIC_SCHOOL_LOCATIONS then :domestic_school + when *FOREIGN_SCHOOL_LOCATIONS then :foreign_school + else :unknown_school_location + end + user.is_kip = sf_school&.is_kip || sf_school&.is_child_of_kip + + cached_school = schools_by_sf_id[sf_school&.id] + if cached_school.nil? && !sf_school.nil? + @metrics.increment(:users_school_not_cached) + Audit.record(user, :user_school_not_cached, sf_school_id: sf_school.id) + else + user.school = cached_school + end + end + + def apply_adoption_status(user, sf_contact) + unless sf_contact.adoption_status.blank? + user.using_openstax = ADOPTION_STATUSES[sf_contact.adoption_status] + end + user.adopter_status = sf_contact.adoption_status + end + + def emit_threshold_alerts + conflicts = @metrics.counters[:contact_id_conflicts].to_i + threshold_conflicts = Settings::Salesforce.alert_contact_id_conflict_count + if conflicts > threshold_conflicts + @metrics.alert!(:contact_id_conflict_count, value: conflicts, threshold: threshold_conflicts) + end + + updated = @metrics.counters[:users_updated].to_i + swaps_counter = @metrics.counters[:contact_id_swaps] + swaps = swaps_counter.is_a?(Hash) ? swaps_counter[:total].to_i : swaps_counter.to_i + if updated > 0 + rate = (swaps.to_f / updated * 100).round(1) + threshold = Settings::Salesforce.alert_contact_id_swap_rate_pct + @metrics.alert!(:contact_id_swap_rate_high, value: rate, threshold: threshold) if rate > threshold + end + + unknown = @metrics.counters[:unknown_accounts_uuids].to_i + threshold_unknown = Settings::Salesforce.alert_unknown_uuid_count + if unknown > threshold_unknown + @metrics.alert!(:unknown_accounts_uuid_count, value: unknown, threshold: threshold_unknown) + end + end + + def log(msg) + Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) } + end + end +end diff --git a/app/services/salesforce/sync_schools.rb b/app/services/salesforce/sync_schools.rb new file mode 100644 index 0000000000..892ed78c09 --- /dev/null +++ b/app/services/salesforce/sync_schools.rb @@ -0,0 +1,100 @@ +module Salesforce + # Caches Salesforce Account (School) records into the local schools table. + # Logic moved verbatim from UpdateSchoolSalesforceInfo; wrapped with + # Salesforce::Metrics for per-run Sentry check-ins. + class SyncSchools + SLUG = 'update-school-salesforce'.freeze + BATCH_SIZE = 250 + + SF_TO_DB_CACHE_COLUMNS_MAP = { + id: :salesforce_id, + name: :name, + city: :city, + state: :state, + country: :country, + type: :type, + school_location: :location, + is_kip: :is_kip, + is_child_of_kip: :is_child_of_kip, + sheerid_school_name: :sheerid_school_name, + has_assignable_contacts: :has_assignable_contacts + }.freeze + + def self.call + new.call + end + + def call + metrics = Metrics.new(run: 'sync_schools', slug: SLUG) + metrics.start! + log 'Starting SyncSchools' + + remove_deleted_schools + schools_updated = upsert_schools_from_sf + + metrics.increment(:schools_updated, by: schools_updated) + metrics.emit(status: :ok) + log "Finished updating #{schools_updated} schools" + rescue StandardError => e + Sentry.capture_exception(e) + metrics.emit(status: :error, extra: { error: e.class.name, message: e.message }) + raise + end + + private + + def remove_deleted_schools + School.where( + 'NOT EXISTS (SELECT * FROM "users" WHERE "users"."school_id" = "schools"."id")' + ).find_in_batches(batch_size: BATCH_SIZE) do |schools| + salesforce_ids = schools.map(&:salesforce_id) + existing_salesforce_ids = Salesforce::Records::School.select(:id).where(id: salesforce_ids).map(&:id) + deleted_salesforce_ids = salesforce_ids - existing_salesforce_ids + School.where(salesforce_id: deleted_salesforce_ids).delete_all + end + end + + def upsert_schools_from_sf + schools_updated = 0 + last_id = nil + + loop do + sf_schools = Salesforce::Records::School.order(:Id).limit(BATCH_SIZE) + sf_schools = sf_schools.where("Id > '#{last_id}'") unless last_id.nil? + sf_schools = sf_schools.to_a + last_id = sf_schools.last&.id + + begin + schools_by_sf_id = School.where(salesforce_id: sf_schools.map(&:id)).index_by(&:salesforce_id) + + schools = sf_schools.map do |sf_school| + school = schools_by_sf_id[sf_school.id] + schools_updated += 1 if school.nil? + school = School.new(salesforce_id: sf_school.id) if school.nil? + + SF_TO_DB_CACHE_COLUMNS_MAP.each do |sf_col, db_col| + school.public_send("#{db_col}=", sf_school.public_send(sf_col)) + end + + school.changed? ? school : nil + end.compact + + School.import( + schools, validate: false, + on_duplicate_key_update: { conflict_target: [:salesforce_id], columns: SF_TO_DB_CACHE_COLUMNS_MAP.values } + ) unless schools.empty? + rescue StandardError => se + Sentry.capture_exception(se) + end + + break if sf_schools.length < BATCH_SIZE + end + + schools_updated + end + + def log(msg) + Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) } + end + end +end diff --git a/app/services/salesforce/upsert_lead.rb b/app/services/salesforce/upsert_lead.rb new file mode 100644 index 0000000000..f54fea82ee --- /dev/null +++ b/app/services/salesforce/upsert_lead.rb @@ -0,0 +1,102 @@ +module Salesforce + # Orchestrates create-or-update of a Salesforce Lead for a User. + # Replaces the bulk of the previous Newflow::CreateOrUpdateSalesforceLead. + class UpsertLead + PERSIST_RETRIES = 3 + FIND_ME_A_HOME = 'Find Me A Home'.freeze + + def self.call(user:) + new(user).call + end + + def initialize(user) + @user = user + end + + def call + return if @user.nil? + + Audit.record(@user, :upsert_lead_begin) + + ensure_school_or_fallback + ResolveFacultyStatus.from_signup(@user) + + result = Lookup.lead_for(@user) + lead = result.lead + + if lead.nil? && existing_contact_owns_user? + Audit.record(@user, :upsert_lead_skipped_user_has_contact, + contact_id: @user.salesforce_contact_id) + return + end + + lead ||= Salesforce::Records::Lead.new( + email: @user.best_email_address_for_salesforce, + accounts_uuid: @user.uuid + ) + BuildLead.apply(lead, @user) + + if lead.save + persist_lead_id(lead) + Audit.record(@user, :upsert_lead_saved, + lead_id: lead.id, matched_by: result.matched_by) + else + Audit.record(@user, :upsert_lead_save_failed, + errors: Array(lead.errors&.full_messages)) + Sentry.capture_message( + "Salesforce lead save failed for user #{@user.id}: " \ + "#{lead.errors&.full_messages&.join(', ')}" + ) + end + end + + private + + def ensure_school_or_fallback + return if @user.school&.salesforce_id + fallback = Salesforce::Records::School.find_by({ name: FIND_ME_A_HOME }) + unless fallback + raise "Salesforce '#{FIND_ME_A_HOME}' school not found — cannot assign fallback school for user #{@user.id}" + end + # If the local schools cache doesn't have the fallback yet, create a + # stub row with the minimum NOT NULL columns. The next SyncSchools run + # will populate the rest. Without this, BuildLead reads + # user.school&.salesforce_id as nil and the saved Lead has no + # account_id / school_id link to the SF School. + @user.school = School.find_or_create_by!(salesforce_id: fallback.id) do |school| + school.name = fallback.name + school.is_kip = fallback.respond_to?(:is_kip) ? !!fallback.is_kip : false + school.is_child_of_kip = fallback.respond_to?(:is_child_of_kip) ? !!fallback.is_child_of_kip : false + end + end + + def existing_contact_owns_user? + return false if @user.salesforce_contact_id.blank? + contact = Lookup.contact_for(@user) + if contact.nil? + prior = @user.salesforce_contact_id + @user.salesforce_contact_id = nil + @user.save + Audit.record(@user, :stale_contact_id_cleared, contact_id: prior) + return false + end + true + end + + def persist_lead_id(lead) + @user.salesforce_lead_id = lead.id + PERSIST_RETRIES.times do |attempt| + return if @user.save + Audit.record(@user, :lead_id_persist_retry, + attempt: attempt + 1, errors: @user.errors.full_messages) + @user.reload + @user.salesforce_lead_id = lead.id + end + Audit.record(@user, :lead_id_persist_failed, + lead_id: lead.id, errors: @user.errors.full_messages) + Sentry.capture_message( + "lead_id persist failed for user #{@user.id}, lead #{lead.id}" + ) + end + end +end diff --git a/app/services/salesforce/verify.rb b/app/services/salesforce/verify.rb new file mode 100644 index 0000000000..5996f1c6b3 --- /dev/null +++ b/app/services/salesforce/verify.rb @@ -0,0 +1,35 @@ +module Salesforce + # Ownership and replacement checks. A Salesforce Lead or Contact "owns" a + # user when its Accounts_UUID__c matches the user's UUID, or is blank (so we + # can adopt a legacy unowned record). Merged-away or deleted Contacts never + # own anyone. + module Verify + module_function + + def lead_owns_user?(lead, user) + return false if lead.nil? + lead.accounts_uuid.blank? || lead.accounts_uuid == user.uuid + end + + def contact_owns_user?(contact, user) + return false if contact.nil? + return false if contact.master_record_id.present? + return false if contact.is_deleted + contact.accounts_uuid == user.uuid + end + + # Is it safe to replace the stored salesforce_contact_id with `by`? + # Returns: + # :gone — previous Contact missing or deleted in Salesforce + # :merged — previous Contact has been merged into `by` + # :uuid_cleared — previous Contact's Accounts_UUID__c is now blank + # false — both records are live and own this user (human review) + def contact_can_be_replaced?(previous_id:, by:, user:) + prev = Salesforce::Records::Contact.find_by({ id: previous_id }) + return :gone if prev.nil? || prev.is_deleted + return :merged if prev.master_record_id.present? && prev.master_record_id == by.id + return :uuid_cleared if prev.accounts_uuid.blank? && by.accounts_uuid == user.uuid + false + end + end +end diff --git a/app/views/admin/salesforce_drift_findings/index.html.erb b/app/views/admin/salesforce_drift_findings/index.html.erb new file mode 100644 index 0000000000..b49ae7da35 --- /dev/null +++ b/app/views/admin/salesforce_drift_findings/index.html.erb @@ -0,0 +1,60 @@ +

Salesforce drift findings

+ +<%= form_with url: admin_salesforce_drift_findings_path, method: :get, local: true do %> + + <%= submit_tag 'Filter', name: nil %> +<% end %> + + + + + + + + + + + + + + + <% @findings.each do |f| %> + + + + + + + + + + <% end %> + +
CategoryUserSF RecordFirst seenLast seenDetails
<%= f.category %> + <% if f.user_id %> + <%= link_to f.user_id, edit_admin_user_path(f.user_id) %> + <% else %> + — + <% end %> + + <% if f.salesforce_record_id.present? %> + <% sf_host = Rails.application.secrets.dig(:salesforce, :instance_url) %> + <% if sf_host.present? %> + <%= link_to "#{f.salesforce_record_type}/#{f.salesforce_record_id}", + "#{sf_host}/#{f.salesforce_record_id}", target: '_blank', rel: 'noopener' %> + <% else %> + <%= "#{f.salesforce_record_type}/#{f.salesforce_record_id}" %> + <% end %> + <% else %> + — + <% end %> + <%= f.first_seen_at.strftime('%Y-%m-%d %H:%M') %><%= f.last_seen_at.strftime('%Y-%m-%d %H:%M') %>
<%= JSON.pretty_generate(f.details) rescue f.details.inspect %>
+ <%= button_to 'Mark resolved', admin_salesforce_drift_finding_path(f), + method: :patch, data: { confirm: 'Mark resolved?' } %> +
+ +

<%= pluralize(@findings.size, 'open finding') %> shown (max 500).

diff --git a/app/views/admin/users/_salesforce_timeline.html.erb b/app/views/admin/users/_salesforce_timeline.html.erb new file mode 100644 index 0000000000..bcee0bd3cf --- /dev/null +++ b/app/views/admin/users/_salesforce_timeline.html.erb @@ -0,0 +1,29 @@ +
+

Salesforce timeline

+ <% if @salesforce_timeline.nil? || @salesforce_timeline.empty? %> +

No Salesforce events recorded for this user.

+ <% else %> + + + + + + + + + + <% @salesforce_timeline.each do |event| %> + + + + + + <% end %> + +
WhenEventData
<%= event.created_at.strftime('%Y-%m-%d %H:%M:%S') %><%= event.event_type %>
<%= JSON.pretty_generate(event.event_data) rescue event.event_data.inspect %>
+

+ <%= link_to 'Open drift findings for this user', + admin_salesforce_drift_findings_path(user_id: @user.id) %> +

+ <% end %> +
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb index a8fe71b721..0f8c43ce76 100644 --- a/app/views/admin/users/edit.html.erb +++ b/app/views/admin/users/edit.html.erb @@ -4,3 +4,5 @@ <% @page_header = 'User Details' %> <%= render 'form' %> + +<%= render 'salesforce_timeline' %> diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf9dc..45d0256d76 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -14,3 +14,7 @@ # ActiveSupport::Inflector.inflections(:en) do |inflect| # inflect.acronym 'RESTful' # end + +ActiveSupport::Inflector.inflections do |inflect| + inflect.acronym 'OpenStax' +end diff --git a/config/initializers/openstax_salesforce.rb b/config/initializers/openstax_salesforce.rb deleted file mode 100644 index 74682bdd3a..0000000000 --- a/config/initializers/openstax_salesforce.rb +++ /dev/null @@ -1,18 +0,0 @@ -# This initializer always runs before the engine is loaded, but it can -# also be copied to the application's initializers by running the install -# task. Because this code can get run multiple times, make sure to only put -# code here that is amenable to that. - -OpenStax::Salesforce.configure do |config| - salesforce_secrets = Rails.application.secrets.salesforce - - # Username, client id, instance url and private key for connecting to the Salesforce app - config.username = salesforce_secrets[:username] - config.password = salesforce_secrets[:password] - config.security_token = salesforce_secrets[:security_token] - config.consumer_key = salesforce_secrets[:consumer_key] - config.consumer_secret = salesforce_secrets[:consumer_secret] - - config.api_version = salesforce_secrets.fetch :api_version, '51.0' - config.login_domain = salesforce_secrets.fetch :login_domain, 'test.salesforce.com' -end diff --git a/config/initializers/salesforce.rb b/config/initializers/salesforce.rb new file mode 100644 index 0000000000..48e9c8c872 --- /dev/null +++ b/config/initializers/salesforce.rb @@ -0,0 +1,16 @@ +# Wrap in to_prepare so Zeitwerk doesn't unload Salesforce (and our config +# with it) after initialization. Without this we hit the Rails autoload- +# during-init deprecation warning and the configured api_version etc. are +# lost on first reload, falling back to the Configuration class defaults. +Rails.application.reloader.to_prepare do + Salesforce.configure do |config| + secrets = Rails.application.secrets.salesforce + config.username = secrets[:username] + config.password = secrets[:password] + config.security_token = secrets[:security_token] + config.consumer_key = secrets[:consumer_key] + config.consumer_secret = secrets[:consumer_secret] + config.api_version = secrets.fetch(:api_version, '66.0') + config.login_domain = secrets.fetch(:login_domain, 'test.salesforce.com') + end +end diff --git a/config/routes.rb b/config/routes.rb index 9e8ab38230..9066195a74 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -291,6 +291,8 @@ resource :security_log, only: [:show] + resources :salesforce_drift_findings, only: [:index, :update] + delete :delete_contact_info, path: '/contact_infos/:id/verify', controller: :contact_infos, action: :destroy post :verify_contact_info, path: '/contact_infos/:id/verify', diff --git a/db/migrate/20260522060416_create_salesforce_drift_findings.rb b/db/migrate/20260522060416_create_salesforce_drift_findings.rb new file mode 100644 index 0000000000..fe84625840 --- /dev/null +++ b/db/migrate/20260522060416_create_salesforce_drift_findings.rb @@ -0,0 +1,19 @@ +class CreateSalesforceDriftFindings < ActiveRecord::Migration[6.1] + def change + create_table :salesforce_drift_findings do |t| + t.references :user, foreign_key: true, null: true + t.string :category, null: false + t.string :salesforce_record_type + t.string :salesforce_record_id + t.jsonb :details, default: {}, null: false + t.datetime :first_seen_at, null: false + t.datetime :last_seen_at, null: false + t.datetime :resolved_at + t.timestamps + + t.index [:category, :resolved_at] + t.index [:user_id, :category] + t.index :last_seen_at + end + end +end diff --git a/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb b/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb new file mode 100644 index 0000000000..d76338865c --- /dev/null +++ b/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb @@ -0,0 +1,7 @@ +class AddIndexToUsersSalesforceLeadId < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :users, :salesforce_lead_id, algorithm: :concurrently, if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6d3b3e33dc..65ecd5b17e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2026_04_18_065011) do +ActiveRecord::Schema.define(version: 2026_05_22_060438) do # These are extensions that must be enabled in order to support this database enable_extension "citext" @@ -349,6 +349,23 @@ t.index ["contact_info_kind"], name: "index_pre_auth_states_on_contact_info_kind" end + create_table "salesforce_drift_findings", force: :cascade do |t| + t.bigint "user_id" + t.string "category", null: false + t.string "salesforce_record_type" + t.string "salesforce_record_id" + t.jsonb "details", default: {}, null: false + t.datetime "first_seen_at", null: false + t.datetime "last_seen_at", null: false + t.datetime "resolved_at" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["category", "resolved_at"], name: "index_salesforce_drift_findings_on_category_and_resolved_at" + t.index ["last_seen_at"], name: "index_salesforce_drift_findings_on_last_seen_at" + t.index ["user_id", "category"], name: "index_salesforce_drift_findings_on_user_id_and_category" + t.index ["user_id"], name: "index_salesforce_drift_findings_on_user_id" + end + create_table "schools", force: :cascade do |t| t.string "salesforce_id", null: false t.string "name", null: false @@ -482,6 +499,7 @@ t.index ["login_token"], name: "index_users_on_login_token", unique: true t.index ["role"], name: "index_users_on_role" t.index ["salesforce_contact_id"], name: "index_users_on_salesforce_contact_id" + t.index ["salesforce_lead_id"], name: "index_users_on_salesforce_lead_id" t.index ["school_id"], name: "index_users_on_school_id" t.index ["school_type"], name: "index_users_on_school_type" t.index ["source_application_id"], name: "index_users_on_source_application_id" @@ -492,6 +510,7 @@ add_foreign_key "external_ids", "users" add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "salesforce_drift_findings", "users" add_foreign_key "users", "oauth_applications", column: "source_application_id" add_foreign_key "users", "schools" end diff --git a/lib/settings.rb b/lib/settings.rb index ad351ff6c6..3fe6ee6aad 100644 --- a/lib/settings.rb +++ b/lib/settings.rb @@ -146,6 +146,20 @@ class Store < RailsSettings::Base type: :string, default: 'https://offers.sheerid.com/openstax/staging/teacher/?env=dev' field :number_of_days_contacts_modified, type: :integer, default: 7 field :minimum_recaptcha_score, type: :float, default: 0.2 + + # Salesforce sync redesign (see docs/superpowers/specs/2026-05-20-salesforce-sync-design.md) + field :salesforce_contacts_synced_through, type: :string, default: nil + field :salesforce_contacts_lookback_overlap_hours, type: :integer, default: 1 + field :salesforce_reconcile_max_queries, type: :integer, default: 5000 + field :salesforce_reconcile_pass_cursors, type: :hash, default: {} + field :salesforce_alert_lead_save_failure_rate_pct, type: :integer, default: 2 + field :salesforce_alert_contact_id_conflict_count, type: :integer, default: 5 + field :salesforce_alert_contact_id_swap_rate_pct, type: :integer, default: 5 + field :salesforce_alert_unknown_uuid_count, type: :integer, default: 50 + field :salesforce_alert_drift_open_total, type: :integer, default: 100 + field :salesforce_alert_cron_drift_hours, type: :integer, default: 2 + field :salesforce_sf_admin_notify_enabled, type: :boolean, default: false + field :salesforce_reconcile_self_heal_enabled, type: :boolean, default: false end mattr_accessor :store diff --git a/lib/settings/feature_flags.rb b/lib/settings/feature_flags.rb index a4bc0500fa..552fac45ae 100644 --- a/lib/settings/feature_flags.rb +++ b/lib/settings/feature_flags.rb @@ -38,6 +38,14 @@ def collect_student_count_all_paths=(bool) Settings::Db.store.collect_student_count_all_paths = bool end + def salesforce_reconcile_self_heal + Settings::Db.store.salesforce_reconcile_self_heal_enabled + end + + def salesforce_reconcile_self_heal=(bool) + Settings::Db.store.salesforce_reconcile_self_heal_enabled = bool + end + end end end diff --git a/lib/settings/salesforce.rb b/lib/settings/salesforce.rb index bdcb853fbb..d4d80b056a 100644 --- a/lib/settings/salesforce.rb +++ b/lib/settings/salesforce.rb @@ -27,6 +27,62 @@ def show_support_chat=(bool) Settings::Db.store.show_support_chat = bool end + # SyncContacts cursor — UTC time of the last successful run. + def contacts_synced_through + v = Settings::Db.store.salesforce_contacts_synced_through + v.present? ? Time.iso8601(v) : nil + end + + def contacts_synced_through=(time) + Settings::Db.store.salesforce_contacts_synced_through = time&.utc&.iso8601 + end + + def contacts_lookback_overlap_hours + Settings::Db.store.salesforce_contacts_lookback_overlap_hours + end + + # Reconcile budget — soft cap on Salesforce queries per run. + def reconcile_max_queries + Settings::Db.store.salesforce_reconcile_max_queries + end + + def reconcile_pass_cursors + Settings::Db.store.salesforce_reconcile_pass_cursors || {} + end + + def reconcile_pass_cursors=(hash) + Settings::Db.store.salesforce_reconcile_pass_cursors = hash + end + + # Threshold knobs for the per-run alert checks. + def alert_lead_save_failure_rate_pct + Settings::Db.store.salesforce_alert_lead_save_failure_rate_pct + end + + def alert_contact_id_conflict_count + Settings::Db.store.salesforce_alert_contact_id_conflict_count + end + + def alert_contact_id_swap_rate_pct + Settings::Db.store.salesforce_alert_contact_id_swap_rate_pct + end + + def alert_unknown_uuid_count + Settings::Db.store.salesforce_alert_unknown_uuid_count + end + + def alert_drift_open_total + Settings::Db.store.salesforce_alert_drift_open_total + end + + def alert_cron_drift_hours + Settings::Db.store.salesforce_alert_cron_drift_hours + end + + def sf_admin_notify_enabled + Settings::Db.store.salesforce_sf_admin_notify_enabled + end + end end diff --git a/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake b/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake index ac1a7eed99..74384916f8 100644 --- a/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake +++ b/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake @@ -16,12 +16,12 @@ namespace :accounts do end users.each do |user| - lead = OpenStax::Salesforce::Remote::Lead.select(:id, :verification_status).find_by(accounts_uuid: user.uuid) + lead = Salesforce::Records::Lead.select(:id, :verification_status).find_by(accounts_uuid: user.uuid) if lead.nil? Newflow::CreateOrUpdateSalesforceLead.call(user: user) else - # set the lead id, this will update their status in UpdateUserLeadInfo + # set the lead id; Reconcile will verify it and attach a Contact when one exists user.salesforce_lead_id = lead.id user.save end diff --git a/lib/tasks/accounts/update_adopter_status.rake b/lib/tasks/accounts/update_adopter_status.rake index b1e0611518..10e416e054 100644 --- a/lib/tasks/accounts/update_adopter_status.rake +++ b/lib/tasks/accounts/update_adopter_status.rake @@ -5,7 +5,7 @@ namespace :accounts do loop do users = User.where(adopter_status: nil).where.not(salesforce_contact_id: nil).limit(250) - contacts = OpenStax::Salesforce::Remote::Contact.select( + contacts = Salesforce::Records::Contact.select( :id, :adoption_status, :accounts_uuid diff --git a/lib/tasks/cron/day.rake b/lib/tasks/cron/day.rake index cf2bf8d4dc..a4f74bcc05 100644 --- a/lib/tasks/cron/day.rake +++ b/lib/tasks/cron/day.rake @@ -8,6 +8,9 @@ namespace :cron do Rails.logger.info 'UpdateSalesforceAssignableFields.call' OpenStax::RescueFrom.this { UpdateSalesforceAssignableFields.call } + Rails.logger.info 'Salesforce::Reconcile.call' + OpenStax::RescueFrom.this { Salesforce::Reconcile.call } + Rails.logger.debug 'Finished daily cron' end end diff --git a/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml b/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml deleted file mode 100644 index 761ae1c884..0000000000 --- a/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml +++ /dev/null @@ -1,249 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20BillingCity,%20BillingState,%20BillingCountry,%20Type,%20School_Location__c,%20SheerID_School_Name__c,%20K_I_P__c,%20child_of_kip__c,%20Total_School_Enrollment__c,%20Has_Assignable_Contacts__c%20FROM%20Account%20WHERE%20(Name%20IN%20(%27RSpec%20University%27))" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.4 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:05:06 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Set-Cookie: - - BrowserId=tAbwQIWGEe-i5nnxlZkMbg; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - X-Robots-Tag: - - none - Vary: - - Accept-Encoding - Sforce-Limit-Info: - - api-usage=73995/305000 - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 1d531721139fbff90848e8fed4ca0720 - X-Request-Id: - - 1d531721139fbff90848e8fed4ca0720 - X-Sfdc-Edge-Cache: - - MISS - body: - encoding: ASCII-8BIT - string: '{"totalSize":0,"done":true,"records":[]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:06 GMT -- request: - method: post - uri: "/services/data/v51.0/sobjects/Account" - body: - encoding: UTF-8 - string: '{"Name":"RSpec University"}' - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/json - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 201 - message: Created - headers: - Date: - - Tue, 08 Oct 2024 15:05:07 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Set-Cookie: - - BrowserId=tDNXG4WGEe-wDwsazMErgw; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Location: - - "/services/data/v51.0/sobjects/Account/001Pc00000LuBMUIA3" - Vary: - - Accept-Encoding - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73994/305000 - X-Content-Type-Options: - - nosniff - Server: - - sfdcedge - X-Sfdc-Request-Id: - - d667f17af657d93ea9ed7db894cbb01a - X-Request-Id: - - d667f17af657d93ea9ed7db894cbb01a - body: - encoding: ASCII-8BIT - string: '{"id":"001Pc00000LuBMUIA3","success":true,"errors":[]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:07 GMT -- request: - method: post - uri: "/services/data/v51.0/sobjects/Contact" - body: - encoding: UTF-8 - string: '{"FirstName":"Ricardo","LastName":"Reynolds","Email":"coy_mante@rennerryan.co"}' - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/json - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 201 - message: Created - headers: - Date: - - Tue, 08 Oct 2024 15:05:08 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Set-Cookie: - - BrowserId=tNi-loWGEe-y9O39Swt3yg; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:07 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:07 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:07 - GMT; Max-Age=31536000; secure - X-Content-Type-Options: - - nosniff - Vary: - - Accept-Encoding - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73994/305000 - Location: - - "/services/data/v51.0/sobjects/Contact/003Pc00000NsWSHIA3" - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 489526c71950273d3bb34cf207785209 - X-Request-Id: - - 489526c71950273d3bb34cf207785209 - body: - encoding: ASCII-8BIT - string: '{"id":"003Pc00000NsWSHIA3","success":true,"errors":[]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:08 GMT -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%27003Pc00000NsWSHIA3%27)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.4 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:05:08 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73995/305000 - Set-Cookie: - - BrowserId=tYayXIWGEe-ww7eu90yMBg; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:08 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:08 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:08 - GMT; Max-Age=31536000; secure - X-Content-Type-Options: - - nosniff - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 91376a8284ef074935f855444a168604 - X-Request-Id: - - 91376a8284ef074935f855444a168604 - X-Sfdc-Edge-Cache: - - MISS - body: - encoding: ASCII-8BIT - string: '{"totalSize":1,"done":true,"records":[{"attributes":{"type":"Contact","url":"/services/data/v51.0/sobjects/Contact/003Pc00000NsWSHIA3"},"Id":"003Pc00000NsWSHIA3","Name":"Ricardo - Reynolds","FirstName":"Ricardo","LastName":"Reynolds","Email":"coy_mante@rennerryan.co","FV_Status__c":null,"LastModifiedDate":"2024-10-08T15:05:08.000+0000","AccountId":null,"School_Type__c":null,"All_Emails__c":"coy_mante@rennerryan.co","Adoption_Status__c":"Not - Adopter","Accounts_UUID__c":null,"LeadSource":null,"Signup_Date__c":null,"Assignable_Interest__c":null,"Assignable_Adoption_Date__c":null}]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:08 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml deleted file mode 100644 index ef863691d0..0000000000 --- a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml +++ /dev/null @@ -1,63 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%270010v000002Wo0qAAC%27)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.4 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:05:05 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73994/305000 - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Vary: - - Accept-Encoding - X-Content-Type-Options: - - nosniff - Set-Cookie: - - BrowserId=s5KqvoWGEe-oQ-O_-Fe-kw; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:05 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:05 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:05 - GMT; Max-Age=31536000; secure - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 0f1add6249c71c3786b963e7dbee805c - X-Request-Id: - - 0f1add6249c71c3786b963e7dbee805c - X-Sfdc-Edge-Cache: - - MISS - body: - encoding: ASCII-8BIT - string: '{"totalSize":0,"done":true,"records":[]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:05 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml deleted file mode 100644 index d606ede9e1..0000000000 --- a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml +++ /dev/null @@ -1,61 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%27somethingwonky%27)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.4 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 400 - message: Bad Request - headers: - Date: - - Tue, 08 Oct 2024 15:05:06 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73994/305000 - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Set-Cookie: - - BrowserId=s9IkRYWGEe-vVKF0vQBeQQ; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 - GMT; Max-Age=31536000; secure - Server: - - sfdcedge - X-Sfdc-Request-Id: - - b224126dea1d50eea19f4cbe8c6fa855 - X-Request-Id: - - b224126dea1d50eea19f4cbe8c6fa855 - body: - encoding: UTF-8 - string: '[{"message":"\nAssignable_Adoption_Date__c FROM Contact WHERE (Id = - ''somethingwonky'') LIMIT 1\n ^\nERROR - at Row:1:Column:258\ninvalid ID field: somethingwonky","errorCode":"INVALID_QUERY_FILTER_OPERATOR"}]' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:06 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml b/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml deleted file mode 100644 index 21e182a655..0000000000 --- a/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https:///services/oauth2/token - body: - encoding: US-ASCII - string: grant_type=password&client_id=&client_secret=&username=&password= - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/x-www-form-urlencoded - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:05:04 GMT - Set-Cookie: - - BrowserId=suMwfYWGEe-7dIfpWPOQQA; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:04 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:05:04 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:05:04 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Expires: - - Thu, 01 Jan 1970 00:00:00 GMT - X-Readonlymode: - - 'false' - Content-Type: - - application/json;charset=UTF-8 - Vary: - - Accept-Encoding - Transfer-Encoding: - - chunked - body: - encoding: ASCII-8BIT - string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1728399904705","signature":""}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:04 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml b/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml deleted file mode 100644 index 484c3538d8..0000000000 --- a/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml +++ /dev/null @@ -1,52 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https:///services/oauth2/token - body: - encoding: US-ASCII - string: grant_type=password&client_id=&client_secret=&username=&password= - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/x-www-form-urlencoded - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Fri, 18 Oct 2024 23:41:46 GMT - Set-Cookie: - - BrowserId=iWXHOY2qEe-om4-Ar5tPbw; domain=.salesforce.com; path=/; expires=Sat, - 18-Oct-2025 23:41:46 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:0; path=/; expires=Sat, 18-Oct-2025 23:41:46 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Sat, 18-Oct-2025 23:41:46 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Expires: - - Thu, 01 Jan 1970 00:00:00 GMT - X-Readonlymode: - - 'false' - Content-Type: - - application/json;charset=UTF-8 - Vary: - - Accept-Encoding - Transfer-Encoding: - - chunked - body: - encoding: ASCII-8BIT - string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1729294906256","signature":""}' - recorded_at: Fri, 18 Oct 2024 23:41:46 GMT -recorded_with: VCR 6.3.1 diff --git a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml b/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml deleted file mode 100644 index e655045b83..0000000000 --- a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https:///services/oauth2/token - body: - encoding: US-ASCII - string: grant_type=password&client_id=&client_secret=&username=&password= - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/x-www-form-urlencoded - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:04:38 GMT - Set-Cookie: - - BrowserId=o5XNPYWGEe-UUm_L3BTUAA; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:04:38 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:04:38 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:04:38 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Expires: - - Thu, 01 Jan 1970 00:00:00 GMT - X-Readonlymode: - - 'false' - Content-Type: - - application/json;charset=UTF-8 - Vary: - - Accept-Encoding - Transfer-Encoding: - - chunked - body: - encoding: ASCII-8BIT - string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1728399879076","signature":""}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:04:39 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml b/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml deleted file mode 100644 index 42f8ba44cd..0000000000 --- a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Salutation,%20Title,%20Subject__c,%20Subject_Interest__c,%20Company,%20City,%20State,%20StateCode,%20Country,%20Phone,%20Website,%20Status,%20Email,%20LeadSource,%20Newsletter__c,%20Newsletter_Opt_In__c,%20Adoption_Status__c,%20AdoptionsJSON__c,%20Number_of_Students__c,%20Accounts_ID__c,%20Accounts_UUID__c,%20Application_Source__c,%20Role__c,%20Position__c,%20who_chooses_books__c,%20FV_Status__c,%20BRI_Marketing__c,%20Title_1_school__c,%20SheerID_School_Name__c,%20Instant_Conversion__c,%20Signup_Date__c,%20Self_Reported_School__c,%20Tracking_Parameters__c,%20Account_ID__c,%20School__c%20FROM%20Lead%20WHERE%20(Email%20=%20NULL)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.3 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Wed, 04 Sep 2024 16:55:25 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Server: - - sfdcedge - body: - encoding: ASCII-8BIT - string: '{"totalSize":0,"done":true,"records":[]}' - http_version: - recorded_at: Wed, 04 Sep 2024 16:55:25 GMT -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20BillingCity,%20BillingState,%20BillingCountry,%20Type,%20School_Location__c,%20SheerID_School_Name__c,%20K_I_P__c,%20child_of_kip__c,%20Total_School_Enrollment__c,%20Has_Assignable_Contacts__c%20FROM%20Account%20WHERE%20(Name%20=%20%27Find%20Me%20A%20Home%27)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.3 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Wed, 04 Sep 2024 16:55:26 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Set-Cookie: - - BrowserId=fBTaLmreEe-4jc39-qgTow; domain=.salesforce.com; path=/; expires=Thu, - 04-Sep-2025 16:55:26 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:26 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:26 - GMT; Max-Age=31536000; secure - Vary: - - Accept-Encoding - Sforce-Limit-Info: - - api-usage=37968/5000000 - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 25f505e7c7c08bdbfdf32fde9d97142b - X-Sfdc-Edge-Cache: - - MISS - body: - encoding: ASCII-8BIT - string: '{"totalSize":1,"done":true,"records":[{"attributes":{"type":"Account","url":"/services/data/v51.0/sobjects/Account/0016f00002iPs9mAAC"},"Id":"0016f00002iPs9mAAC","Name":"Find - Me A Home","BillingCity":"Abu Dhabi","BillingState":null,"BillingCountry":"United - Arab Emirates","Type":"Other","School_Location__c":"Foreign","SheerID_School_Name__c":"Find - Me A Home","K_I_P__c":false,"child_of_kip__c":false,"Total_School_Enrollment__c":null,"20Has_Assignable_Contacts__c":false}]}' - http_version: - recorded_at: Wed, 04 Sep 2024 16:55:26 GMT -- request: - method: post - uri: "/services/data/v51.0/sobjects/Lead" - body: - encoding: UTF-8 - string: '{"FirstName":"Max","LastName":"Liebermann","Subject_Interest__c":"AP - Macro Econ","Company":"Test University","Country":"United States","Phone":"+17133484799","LeadSource":"Account - Creation","Newsletter__c":false,"Newsletter_Opt_In__c":false,"Adoption_Status__c":"Confirmed - Adoption Won","Number_of_Students__c":"35","Accounts_ID__c":1,"Accounts_UUID__c":"86ed61c4-e556-4a05-9c55-5453bbdd9f28","Application_Source__c":"Accounts","Role__c":"Instructor","Position__c":"instructor","who_chooses_books__c":"instructor","FV_Status__c":"pending_faculty","BRI_Marketing__c":false,"Title_1_school__c":false,"Signup_Date__c":"2024-09-04T16:55:26.508+0000","Self_Reported_School__c":"Test - University","Tracking_Parameters__c":"https://dev.openstax.org/accounts/i/signup/","Account_ID__c":"0016f00002iPs9mAAC","School__c":"0016f00002iPs9mAAC"}' - headers: - User-Agent: - - Faraday v1.10.3 - Content-Type: - - application/json - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 201 - message: Created - headers: - Date: - - Wed, 04 Sep 2024 16:55:32 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Location: - - "/services/data/v51.0/sobjects/Lead/00QOx00000aLVDoMAO" - Vary: - - Accept-Encoding - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Sforce-Limit-Info: - - api-usage=37968/5000000 - Set-Cookie: - - BrowserId=fEgf2WreEe-pC3-63Rbixw; domain=.salesforce.com; path=/; expires=Thu, - 04-Sep-2025 16:55:27 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:27 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:27 - GMT; Max-Age=31536000; secure - X-Content-Type-Options: - - nosniff - X-Robots-Tag: - - none - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 195fe1b23acf0aa4438508370eda95c5 - body: - encoding: ASCII-8BIT - string: '{"id":"00QOx00000aLVDoMAO","success":true,"errors":[]}' - http_version: - recorded_at: Wed, 04 Sep 2024 16:55:32 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml b/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml deleted file mode 100644 index c058ca1651..0000000000 --- a/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml +++ /dev/null @@ -1,54 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https:///services/oauth2/token - body: - encoding: US-ASCII - string: grant_type=password&client_id=&client_secret=&username=&password= - headers: - User-Agent: - - Faraday v1.0.1 - Content-Type: - - application/x-www-form-urlencoded - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Wed, 15 Nov 2023 16:14:59 GMT - Set-Cookie: - - BrowserId=H-5da4PSEe6uFMnuBFn0Jg; domain=.salesforce.com; path=/; expires=Thu, - 14-Nov-2024 16:14:59 GMT; Max-Age=31536000 - - CookieConsentPolicy=0:0; path=/; expires=Thu, 14-Nov-2024 16:14:59 GMT; Max-Age=31536000 - - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Thu, 14-Nov-2024 16:14:59 - GMT; Max-Age=31536000 - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - X-Xss-Protection: - - 1; mode=block - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Expires: - - Thu, 01 Jan 1970 00:00:00 GMT - X-Readonlymode: - - 'false' - Content-Type: - - application/json;charset=UTF-8 - Vary: - - Accept-Encoding - Transfer-Encoding: - - chunked - body: - encoding: ASCII-8BIT - string: '{"access_token":"","instance_url":"","id":"https:///id/00D040000003rgWEAQ/005U0000005MXdmIAG","token_type":"Bearer","issued_at":"1700064900732","signature":""}' - http_version: - recorded_at: Wed, 15 Nov 2023 16:15:00 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb b/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb new file mode 100644 index 0000000000..879b22961e --- /dev/null +++ b/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +module Admin + describe SalesforceDriftFindingsController, type: :controller do + let(:admin) { FactoryBot.create :user, :admin, :terms_agreed } + + before { controller.sign_in! admin } + + describe 'GET #index' do + let!(:finding) { FactoryBot.create(:salesforce_drift_finding) } + + it 'lists open findings' do + get :index + expect(response).to have_http_status(:ok) + expect(assigns(:findings)).to include(finding) + end + + it 'filters by category' do + FactoryBot.create(:salesforce_drift_finding, category: 'sf_orphan_lead') + get :index, params: { category: 'sf_orphan_lead' } + expect(assigns(:findings).map(&:category)).to all(eq('sf_orphan_lead')) + end + + it 'filters by user_id' do + user = FactoryBot.create(:user) + mine = FactoryBot.create(:salesforce_drift_finding, user: user) + get :index, params: { user_id: user.id } + expect(assigns(:findings)).to contain_exactly(mine) + end + + it 'excludes resolved findings' do + resolved = FactoryBot.create(:salesforce_drift_finding, resolved_at: 1.day.ago) + get :index + expect(assigns(:findings)).not_to include(resolved) + end + end + + describe 'PATCH #update' do + let!(:finding) { FactoryBot.create(:salesforce_drift_finding) } + + it 'marks the finding resolved' do + patch :update, params: { id: finding.id } + expect(finding.reload.resolved_at).not_to be_nil + end + + it 'redirects to the index with a notice' do + patch :update, params: { id: finding.id } + expect(response).to redirect_to(admin_salesforce_drift_findings_path) + expect(flash[:notice]).to match(/resolved/i) + end + end + end +end diff --git a/spec/factories/salesforce_drift_findings.rb b/spec/factories/salesforce_drift_findings.rb new file mode 100644 index 0000000000..be0f36ae75 --- /dev/null +++ b/spec/factories/salesforce_drift_findings.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :salesforce_drift_finding do + category { 'sf_orphan_contact' } + salesforce_record_type { 'Contact' } + salesforce_record_id { SecureRandom.hex(8) } + details { {} } + first_seen_at { Time.current } + last_seen_at { Time.current } + end +end diff --git a/spec/features/admin/change_salesforce_contact_manually_spec.rb b/spec/features/admin/change_salesforce_contact_manually_spec.rb index 07ea6df8d5..c0786b4e24 100644 --- a/spec/features/admin/change_salesforce_contact_manually_spec.rb +++ b/spec/features/admin/change_salesforce_contact_manually_spec.rb @@ -1,14 +1,6 @@ require 'rails_helper' -require 'vcr_helper' - -feature 'Change Salesforce contact manually', js: true, vcr: VCR_OPTS do - before(:all) do - VCR.use_cassette('Change_Salesforce_contact_manually/sf_setup', VCR_OPTS) do - @proxy = SalesforceProxy.new - @proxy.setup_cassette - end - end +feature 'Change Salesforce contact manually', js: true do before(:each) do @admin_user = create_admin_user visit '/' @@ -21,6 +13,7 @@ it 'can be removed' do @target_user.update_attribute(:salesforce_contact_id, 'something') + visit "/admin/users/#{@target_user.id}/edit" fill_in 'user_salesforce_contact_id', with: 'remove' click_button 'Save' expect(page).to have_content('successfully updated') @@ -29,29 +22,41 @@ end it 'can be set if the Contact exists in SF' do - contact = @proxy.new_contact - fill_in 'user_salesforce_contact_id', with: contact.id + contact = Salesforce::Records::Contact.new( + id: '003AB000000XYZ', accounts_uuid: @target_user.uuid, + master_record_id: nil, is_deleted: false + ) + allow(Salesforce::Records::Contact).to receive(:find).with('003AB000000XYZ').and_return(contact) + + fill_in 'user_salesforce_contact_id', with: '003AB000000XYZ' click_button 'Save' expect(page).to have_content('successfully updated') @target_user.reload - expect(@target_user.salesforce_contact_id).to eq contact.id + expect(@target_user.salesforce_contact_id).to eq '003AB000000XYZ' end it 'cannot be set if the Contact does not exist in SF' do @target_user.update_attribute(:salesforce_contact_id, 'original') + allow(Salesforce::Records::Contact).to receive(:find).with('0010v000002Wo0qAAC').and_return(nil) + + visit "/admin/users/#{@target_user.id}/edit" fill_in 'user_salesforce_contact_id', with: '0010v000002Wo0qAAC' click_button 'Save' expect(page).to have_content("Can't find") @target_user.reload - expect(@target_user.salesforce_contact_id).to eq "original" + expect(@target_user.salesforce_contact_id).to eq 'original' end it 'cannot be set if the ID is malformed' do @target_user.update_attribute(:salesforce_contact_id, 'original') + allow(Salesforce::Records::Contact).to receive(:find).with('somethingwonky') + .and_raise(StandardError, 'malformed id') + + visit "/admin/users/#{@target_user.id}/edit" fill_in 'user_salesforce_contact_id', with: 'somethingwonky' click_button 'Save' expect(page).to have_content('Failed') @target_user.reload - expect(@target_user.salesforce_contact_id).to eq "original" + expect(@target_user.salesforce_contact_id).to eq 'original' end end diff --git a/spec/features/newflow/student_signup_flow_spec.rb b/spec/features/newflow/student_signup_flow_spec.rb index 9db7c86f13..4bbd2f8c10 100644 --- a/spec/features/newflow/student_signup_flow_spec.rb +++ b/spec/features/newflow/student_signup_flow_spec.rb @@ -1,20 +1,12 @@ require 'rails_helper' -require 'vcr_helper' require 'byebug' module Newflow - feature 'Student signup flow', js: true, vcr: VCR_OPTS do + feature 'Student signup flow', js: true do before do load 'db/seeds.rb' turn_on_student_feature_flag end - before(:all) do - VCR.use_cassette('Newflow/Students/student_signup_flow/sf_setup', VCR_OPTS) do - @proxy = SalesforceProxy.new - @proxy.setup_cassette - end - end - let(:email) do Faker::Internet::email end diff --git a/spec/handlers/verify_email_by_code_spec.rb b/spec/handlers/verify_email_by_code_spec.rb index 2c1a4f18ef..c997c92353 100644 --- a/spec/handlers/verify_email_by_code_spec.rb +++ b/spec/handlers/verify_email_by_code_spec.rb @@ -1,20 +1,12 @@ require 'rails_helper' -require 'vcr_helper' -describe VerifyEmailByCode, type: :handler, vcr: VCR_OPTS do +describe VerifyEmailByCode, type: :handler do subject(:handler_call) { described_class.call(params: params) } let(:params) { { code: email.confirmation_code } } let(:email) { FactoryBot.create(:email_address, user: user) } let(:user) { FactoryBot.create(:user, state: User::UNVERIFIED, role: role) } - before(:all) do - VCR.use_cassette('Newflow_VerifyEmailByCode/sf_setup', VCR_OPTS) do - @proxy = SalesforceProxy.new - @proxy.setup_cassette - end - end - context 'when student' do let(:role) { User.roles[:student] } diff --git a/spec/lib/settings_salesforce_spec.rb b/spec/lib/settings_salesforce_spec.rb new file mode 100644 index 0000000000..a8e80a1e0e --- /dev/null +++ b/spec/lib/settings_salesforce_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe Settings::Salesforce do + describe 'contacts_synced_through' do + after { Settings::Db.store.salesforce_contacts_synced_through = nil } + + it 'round-trips a UTC time as ISO8601' do + t = Time.utc(2026, 5, 1, 12, 0, 0) + Settings::Salesforce.contacts_synced_through = t + expect(Settings::Salesforce.contacts_synced_through).to eq(t) + end + + it 'returns nil when unset' do + Settings::Db.store.salesforce_contacts_synced_through = nil + expect(Settings::Salesforce.contacts_synced_through).to be_nil + end + end + + it 'exposes reconcile_max_queries default' do + expect(Settings::Salesforce.reconcile_max_queries).to eq(5000) + end + + it 'exposes alert thresholds' do + expect(Settings::Salesforce.alert_contact_id_conflict_count).to eq(5) + expect(Settings::Salesforce.alert_contact_id_swap_rate_pct).to eq(5) + expect(Settings::Salesforce.alert_drift_open_total).to eq(100) + end + + it 'exposes the feature flag through Settings::FeatureFlags' do + expect(Settings::FeatureFlags.salesforce_reconcile_self_heal).to be(false) + Settings::FeatureFlags.salesforce_reconcile_self_heal = true + expect(Settings::FeatureFlags.salesforce_reconcile_self_heal).to be(true) + ensure + Settings::FeatureFlags.salesforce_reconcile_self_heal = false + end +end diff --git a/spec/models/salesforce_drift_finding_spec.rb b/spec/models/salesforce_drift_finding_spec.rb new file mode 100644 index 0000000000..64c9520183 --- /dev/null +++ b/spec/models/salesforce_drift_finding_spec.rb @@ -0,0 +1,80 @@ +require 'rails_helper' + +RSpec.describe SalesforceDriftFinding do + let(:user) { FactoryBot.create(:user) } + + it 'is valid with category and timestamps' do + f = described_class.new( + category: 'sf_orphan_contact', + first_seen_at: Time.current, + last_seen_at: Time.current + ) + expect(f).to be_valid + end + + it 'belongs to a user optionally' do + f = FactoryBot.create(:salesforce_drift_finding, user: user) + expect(f.user).to eq(user) + end + + it 'requires category' do + f = described_class.new(first_seen_at: Time.current, last_seen_at: Time.current) + expect(f).not_to be_valid + end + + describe 'scopes' do + let!(:open_f) { FactoryBot.create(:salesforce_drift_finding, resolved_at: nil) } + let!(:resolved_f) { FactoryBot.create(:salesforce_drift_finding, resolved_at: 1.day.ago) } + + it '.open returns only unresolved findings' do + expect(described_class.open).to contain_exactly(open_f) + end + + it '.resolved returns only resolved' do + expect(described_class.resolved).to contain_exactly(resolved_f) + end + end + + describe '.upsert_finding!' do + it 'creates when no matching open finding exists' do + expect { + described_class.upsert_finding!( + user: user, category: 'sf_orphan_contact', + record_type: 'Contact', record_id: 'C1' + ) + }.to change(described_class, :count).by(1) + end + + it 'updates last_seen_at when a matching open finding exists' do + existing = FactoryBot.create(:salesforce_drift_finding, + user: user, category: 'sf_orphan_contact', + salesforce_record_type: 'Contact', salesforce_record_id: 'C1', + last_seen_at: 2.days.ago, resolved_at: nil) + described_class.upsert_finding!( + user: user, category: 'sf_orphan_contact', + record_type: 'Contact', record_id: 'C1' + ) + expect(existing.reload.last_seen_at).to be_within(2.seconds).of(Time.current) + end + + it 'creates a new one when the prior matching finding was resolved' do + FactoryBot.create(:salesforce_drift_finding, + user: user, category: 'sf_orphan_contact', + salesforce_record_type: 'Contact', salesforce_record_id: 'C1', + resolved_at: 1.day.ago) + expect { + described_class.upsert_finding!( + user: user, category: 'sf_orphan_contact', + record_type: 'Contact', record_id: 'C1' + ) + }.to change(described_class, :count).by(1) + end + end + + describe '#resolve!' do + it 'sets resolved_at' do + f = FactoryBot.create(:salesforce_drift_finding, resolved_at: nil) + expect { f.resolve! }.to change { f.reload.resolved_at }.from(nil) + end + end +end diff --git a/spec/models/security_log_spec.rb b/spec/models/security_log_spec.rb index 40e81aa9bc..12cb50ed35 100644 --- a/spec/models/security_log_spec.rb +++ b/spec/models/security_log_spec.rb @@ -19,4 +19,25 @@ it 'cannot be destroyed' do expect{security_log.destroy}.to raise_error ActiveRecord::ReadOnlyRecord end + + it 'includes the new salesforce event types added in the sync redesign' do + %i[ + salesforce_lookup_started + salesforce_lookup_matched_by_uuid + salesforce_upsert_lead_saved + salesforce_upsert_lead_save_failed + salesforce_lead_id_persist_failed + salesforce_stale_contact_id_cleared + salesforce_contact_id_swapped + salesforce_contact_id_conflict + salesforce_contact_skipped_merged_or_deleted + salesforce_user_school_not_cached + salesforce_reconcile_contact_id_cleared + salesforce_reconcile_followed_merge + salesforce_link_restored_by_reconcile + salesforce_contact_id_orphaned + ].each do |sym| + expect(SecurityLog.event_types).to have_key(sym.to_s) + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 68e6004dff..068714b573 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -3,7 +3,6 @@ require 'simplecov_helper' require File.expand_path('../../config/environment', __FILE__) Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } -require 'openstax/salesforce/spec_helpers' require 'rspec/rails' require 'capybara/rails' require 'capybara/email/rspec' @@ -12,8 +11,6 @@ require 'database_cleaner' require 'spec_helper' -include OpenStax::Salesforce::SpecHelpers - # https://github.com/colszowka/simplecov/issues/369#issuecomment-313493152 # Load rake tasks so they can be tested. Rails.application.load_tasks unless defined?(Rake::Task) && Rake::Task.task_defined?('environment') diff --git a/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb b/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb index 913ffc4011..39705d6e64 100644 --- a/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb +++ b/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb @@ -2,7 +2,9 @@ module Newflow describe CreateOrUpdateSalesforceLead, type: :routine do - let!(:school) { FactoryBot.create :school, name: 'Find Me A Home', salesforce_id: 'SF_SCHOOL_HOME' } + let!(:home_school) do + FactoryBot.create :school, name: 'Find Me A Home', salesforce_id: 'SF_SCHOOL_HOME' + end let(:user) do User.create do |u| @@ -29,131 +31,152 @@ module Newflow before do stub_sentry - # Stub the school lookup - allow(OpenStax::Salesforce::Remote::School).to receive(:find_by).with(name: 'Find Me A Home') + allow(Salesforce::Records::School).to receive(:find_by) + .with({ name: 'Find Me A Home' }) .and_return(OpenStruct.new(id: 'SF_SCHOOL_HOME')) end describe 'creating a new lead' do - it 'creates a new lead when none exists' do - # Stub all the search methods to return nil (no existing lead) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) - - # Create a mock lead that will be "saved" - mock_lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead) + it 'creates a new lead when none exists and saves the lead id' do + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil) + + mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) + allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) allow(mock_lead).to receive(:save).and_return(true) allow(mock_lead).to receive(:id).and_return('SF_LEAD_123') described_class.call(user: user) - expect(user.salesforce_lead_id).to eq('SF_LEAD_123') - expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(1) + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_123') + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_saved', user: user).count).to eq(1) end end describe 'finding existing leads' do let(:existing_lead) do - lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) + lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) allow(lead).to receive(:id).and_return('SF_LEAD_EXISTING') allow(lead).to receive(:save).and_return(true) lead end it 'finds and updates existing lead by UUID' do - # Stub to return existing lead when searched by UUID - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(existing_lead) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(existing_lead) described_class.call(user: user) - expect(user.salesforce_lead_id).to eq('SF_LEAD_EXISTING') - expect(SecurityLog.where(event_type: :salesforce_lead_found_by_uuid).count).to eq(1) - expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0) + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_EXISTING') + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_uuid', user: user).count).to eq(1) end it 'finds and updates existing lead by email when UUID search fails' do - # Stub UUID search to return nil, email search to return existing lead - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(existing_lead) + allow(user).to receive(:best_email_address_for_salesforce).and_return('max@example.com') + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'max@example.com' }).and_return(existing_lead) + # User must be findable by id (UpsertLead doesn't go through the User class directly, + # but ActiveRecord-side persistence needs to keep working) + allow(User).to receive(:find).and_return(user) described_class.call(user: user) - expect(user.salesforce_lead_id).to eq('SF_LEAD_EXISTING') - expect(SecurityLog.where(event_type: :salesforce_lead_found_by_email).count).to eq(1) - expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0) + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_EXISTING') + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_email', user: user).count).to eq(1) end - it 'uses stored lead ID if available' do - user.salesforce_lead_id = 'SF_LEAD_STORED' - user.save! - - # Stub to return existing lead when searched by ID - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find).with('SF_LEAD_STORED').and_return(existing_lead) + it 'uses stored lead ID if available and it still owns the user' do + user.update_column(:salesforce_lead_id, 'SF_LEAD_STORED') allow(existing_lead).to receive(:id).and_return('SF_LEAD_STORED') + allow(Salesforce::Records::Lead).to receive(:find).with('SF_LEAD_STORED').and_return(existing_lead) described_class.call(user: user) - expect(user.salesforce_lead_id).to eq('SF_LEAD_STORED') - # Should not search by UUID or email since it found by ID - expect(SecurityLog.where(event_type: :salesforce_lead_found_by_uuid).count).to eq(0) - expect(SecurityLog.where(event_type: :salesforce_lead_found_by_email).count).to eq(0) + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_STORED') + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_stored_id', user: user).count).to eq(1) + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_uuid', user: user).count).to eq(0) + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_email', user: user).count).to eq(0) end end describe 'when lead save fails' do it 'logs to SecurityLog and Sentry' do - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil) - mock_lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead) + mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) + allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) allow(mock_lead).to receive(:save).and_return(false) allow(mock_lead).to receive(:errors).and_return(double(full_messages: ['Some SF error'])) described_class.call(user: user) - expect(SecurityLog.where(event_type: :salesforce_lead_save_failed).count).to eq(1) + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_save_failed', user: user).count).to eq(1) expect(Sentry).to have_received(:capture_message).with(/Salesforce lead save failed for user #{user.id}/) end end - describe 'when user already has a contact' do - let(:existing_contact) do - contact = OpenStruct.new(id: 'SF_CONTACT_123') - contact + describe 'when user already has a contact that still owns them' do + let(:owning_contact) do + Salesforce::Records::Contact.new( + id: 'SF_CONTACT_123', accounts_uuid: user.uuid, + master_record_id: nil, is_deleted: false + ) end - it 'does not create a lead if user already has a contact' do - user.salesforce_contact_id = 'SF_CONTACT_123' - user.save! + it 'does not create a lead' do + user.update_column(:salesforce_contact_id, 'SF_CONTACT_123') + + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil) + allow(Salesforce::Records::Contact).to receive(:find).with('SF_CONTACT_123').and_return(owning_contact) + + expect(Salesforce::Records::Lead).not_to receive(:new) + described_class.call(user: user) + + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_skipped_user_has_contact', user: user).count).to eq(1) + end + end - # Stub all lead searches to return nil - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) + describe 'when stored contact_id no longer owns the user' do + it 'clears the stored contact_id and proceeds to create a lead' do + user.update_column(:salesforce_contact_id, 'SF_CONTACT_STALE') - # Stub contact lookup to return existing contact - allow(OpenStax::Salesforce::Remote::Contact).to receive(:find).with('SF_CONTACT_123').and_return(existing_contact) + # Stored contact lookup returns a contact that doesn't own this user + foreign_contact = Salesforce::Records::Contact.new( + id: 'SF_CONTACT_STALE', accounts_uuid: 'OTHER', + master_record_id: nil, is_deleted: false + ) + allow(Salesforce::Records::Contact).to receive(:find).with('SF_CONTACT_STALE').and_return(foreign_contact) + # UUID-based contact lookup also returns nothing for this user + allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) - result = described_class.call(user: user) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil) - expect(result.outputs.lead).to be_nil - expect(SecurityLog.where(event_type: :user_already_has_contact_not_creating_lead).count).to eq(1) - expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0) + mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) + allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) + allow(mock_lead).to receive(:save).and_return(true) + allow(mock_lead).to receive(:id).and_return('SF_LEAD_NEW') + + described_class.call(user: user) + + expect(user.reload.salesforce_contact_id).to be_nil + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_NEW') + expect(SecurityLog.where(event_type: 'salesforce_stale_contact_id_cleared', user: user).count).to eq(1) end end describe 'expected_start_semester assignment' do let(:mock_lead) do - lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) + lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) allow(lead).to receive(:save).and_return(true) allow(lead).to receive(:id).and_return('SF_LEAD_999') lead end before do - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead) + allow(Salesforce::Records::Lead).to receive(:find_by).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) end [ @@ -171,17 +194,13 @@ module Newflow it 'assigns nil when user.expected_start_semester is nil' do user.update_column(:expected_start_semester, nil) - described_class.call(user: user) - expect(mock_lead.expected_start_semester).to be_nil end it 'assigns nil when user.expected_start_semester is an unrecognized value' do user.update_column(:expected_start_semester, 'garbage') - described_class.call(user: user) - expect(mock_lead.expected_start_semester).to be_nil end end diff --git a/spec/routines/update_salesforce_assignable_fields_spec.rb b/spec/routines/update_salesforce_assignable_fields_spec.rb index f30ee1c49e..99705da0fb 100644 --- a/spec/routines/update_salesforce_assignable_fields_spec.rb +++ b/spec/routines/update_salesforce_assignable_fields_spec.rb @@ -23,15 +23,15 @@ it "updates Salesforce Contact with Assignable user's info" do stub_contacts [ non_assignable_instructor, assignable_instructor ] - expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to( + expect_any_instance_of(Salesforce::Records::Contact).to( receive(:assignable_interest=).with('Fully Integrated').and_call_original ) - expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to( + expect_any_instance_of(Salesforce::Records::Contact).to( receive(:assignable_adoption_date=).with( assignable_instructor.external_ids.map(&:created_at).min.strftime('%Y-%m-%d') ).and_call_original ) - expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to receive(:save!) + expect_any_instance_of(Salesforce::Records::Contact).to receive(:save!) described_class.call end @@ -40,9 +40,9 @@ def stub_contacts(users) sf_contacts = [users].flatten.map do |user| id = user.salesforce_contact_id - [ id, OpenStax::Salesforce::Remote::Contact.new(id: id) ] + [ id, Salesforce::Records::Contact.new(id: id) ] end.to_h - expect(OpenStax::Salesforce::Remote::Contact).to receive(:find) { |id| sf_contacts[id] } + expect(Salesforce::Records::Contact).to receive(:find) { |id| sf_contacts[id] } end end diff --git a/spec/routines/update_school_salesforce_info_spec.rb b/spec/routines/update_school_salesforce_info_spec.rb index 884930671d..2d4214a82d 100644 --- a/spec/routines/update_school_salesforce_info_spec.rb +++ b/spec/routines/update_school_salesforce_info_spec.rb @@ -64,17 +64,17 @@ def stub_schools(schools) attrs['id'] = attrs.delete('salesforce_id') attrs['school_location'] = attrs.delete('location') - OpenStax::Salesforce::Remote::School.new attrs + Salesforce::Records::School.new attrs end select_query = instance_double(ActiveForce::ActiveQuery) - expect(OpenStax::Salesforce::Remote::School).to( + expect(Salesforce::Records::School).to( receive(:select).with(:id).and_return(select_query) ) expect(select_query).to receive(:where).with(id: kind_of(Array)).and_return(sf_schools) order_query = instance_double(ActiveForce::ActiveQuery) - expect(OpenStax::Salesforce::Remote::School).to( + expect(Salesforce::Records::School).to( receive(:order).with(:Id).and_return(order_query) ) expect(order_query).to receive(:limit).with(described_class::BATCH_SIZE).and_return(sf_schools) diff --git a/spec/services/salesforce/audit_spec.rb b/spec/services/salesforce/audit_spec.rb new file mode 100644 index 0000000000..ac0b03696b --- /dev/null +++ b/spec/services/salesforce/audit_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Audit do + let(:user) { FactoryBot.create(:user) } + + describe '.record' do + it 'prepends salesforce_ to the event name' do + expect { + described_class.record(user, :upsert_lead_saved, lead_id: 'X') + }.to change { SecurityLog.where(event_type: 'salesforce_upsert_lead_saved').count }.by(1) + end + + it 'stores details in event_data' do + described_class.record(user, :upsert_lead_saved, lead_id: 'X', matched_by: :uuid) + log = SecurityLog.where(event_type: 'salesforce_upsert_lead_saved').last + expect(log.event_data).to include('lead_id' => 'X', 'matched_by' => 'uuid') + end + + it 'raises if the event_type is not registered' do + expect { + described_class.record(user, :not_a_real_event) + }.to raise_error(ArgumentError, /Unknown Salesforce audit event/) + end + + it 'allows nil user (system-level events)' do + expect { + described_class.record(nil, :upsert_lead_saved, lead_id: 'X') + }.not_to raise_error + end + end +end diff --git a/spec/services/salesforce/build_lead_spec.rb b/spec/services/salesforce/build_lead_spec.rb new file mode 100644 index 0000000000..e92e0175e7 --- /dev/null +++ b/spec/services/salesforce/build_lead_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +RSpec.describe Salesforce::BuildLead do + let(:school) do + FactoryBot.create(:school, + salesforce_id: 'SF1', name: 'Rice', city: 'Houston', + country: 'United States', state: 'Texas') + end + + let(:user) do + FactoryBot.create(:user, + role: 'instructor', first_name: 'A', last_name: 'B', + phone_number: '+15555555555', who_chooses_books: 'instructor', + which_books: 'AP Macro Econ', how_many_students: '50', + using_openstax_how: 'as_primary', expected_start_semester: 'next_semester', + receive_newsletter: true, school: school, faculty_status: 'pending_faculty', + is_profile_complete: true) + end + + let(:lead) { Salesforce::Records::Lead.new(email: 'a@b.com') } + + before do + allow(user).to receive(:books_used_details).and_return( + { 'Calculus Volume 1' => { 'num_students_using_book' => 50, 'how_using_book' => 'Required' } } + ) + end + + it 'maps role=instructor to Instructor and copies role into position' do + described_class.apply(lead, user) + expect(lead.role).to eq('Instructor') + expect(lead.position).to eq('instructor') + end + + it 'maps role=student to Student with nil position' do + user.update!(role: 'student') + described_class.apply(lead, user) + expect(lead.role).to eq('Student') + expect(lead.position).to be_nil + end + + it 'always sets accounts_uuid (invariant for retry idempotency)' do + described_class.apply(lead, user) + expect(lead.accounts_uuid).to eq(user.uuid) + end + + it 'builds adoption_json for non-as_future users' do + described_class.apply(lead, user) + parsed = JSON.parse(lead.adoption_json) + expect(parsed['Books']).to be_an(Array) + expect(parsed['Books'].first).to include('name' => 'Calculus Volume 1', 'students' => 50) + end + + it 'skips adoption_json when using_openstax_how is as_future' do + user.update!(using_openstax_how: 'as_future') + described_class.apply(lead, user) + expect(lead.adoption_json).to be_nil + end + + it 'maps US state full name to lead.state' do + described_class.apply(lead, user) + expect(lead.state).to eq('Texas') + end + + it 'maps US state abbreviation (uppercase) to lead.state_code' do + school.update!(state: 'TX') + described_class.apply(lead, user) + expect(lead.state_code).to eq('TX') + end + + it 'maps adoption_status from using_openstax_how' do + described_class.apply(lead, user) + expect(lead.adoption_status).to eq('Confirmed Adoption Won') + end + + it 'sets verification_status to faculty_status when not no_faculty_info' do + described_class.apply(lead, user) + expect(lead.verification_status).to eq('pending_faculty') + end + + it 'sets verification_status to nil when faculty_status is no_faculty_info' do + user.update!(faculty_status: 'no_faculty_info') + described_class.apply(lead, user) + expect(lead.verification_status).to be_nil + end +end diff --git a/spec/services/salesforce/client_spec.rb b/spec/services/salesforce/client_spec.rb new file mode 100644 index 0000000000..186bfd8423 --- /dev/null +++ b/spec/services/salesforce/client_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Client do + before do + Salesforce.configure do |c| + c.username = 'u' + c.password = 'p' + c.security_token = 't' + c.consumer_key = 'ck' + c.consumer_secret = 'cs' + c.api_version = '66.0' + c.login_domain = 'test.salesforce.com' + end + end + + after { Salesforce.reset_configuration! } + + it 'is a Restforce client' do + expect(described_class.new).to be_a(Restforce::Data::Client) + end + + it 'passes configured credentials through to Restforce::Data::Client#initialize' do + expect_any_instance_of(Restforce::Data::Client).to receive(:initialize).with( + hash_including( + username: 'u', + password: 'p', + security_token: 't', + client_id: 'ck', + client_secret: 'cs', + api_version: '66.0', + host: 'test.salesforce.com' + ) + ).and_call_original + described_class.new + end +end diff --git a/spec/services/salesforce/lookup_spec.rb b/spec/services/salesforce/lookup_spec.rb new file mode 100644 index 0000000000..54579c44a8 --- /dev/null +++ b/spec/services/salesforce/lookup_spec.rb @@ -0,0 +1,99 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Lookup do + let(:user) { FactoryBot.create(:user) } + let(:matching_lead) { Salesforce::Records::Lead.new(id: 'L1', accounts_uuid: user.uuid) } + let(:foreign_lead) { Salesforce::Records::Lead.new(id: 'L2', accounts_uuid: 'OTHER') } + + before do + allow(user).to receive(:best_email_address_for_salesforce).and_return('e@example.com') + end + + describe '.lead_for' do + context 'with a stored salesforce_lead_id that still owns the user' do + it 'matches by stored_id' do + user.update!(salesforce_lead_id: 'L1') + allow(Salesforce::Records::Lead).to receive(:find).with('L1').and_return(matching_lead) + result = described_class.lead_for(user) + expect(result.lead).to eq(matching_lead) + expect(result.matched_by).to eq(:stored_id) + end + end + + context 'when stored id belongs to another user' do + it 'falls through to uuid match and records the disown' do + user.update!(salesforce_lead_id: 'L2') + allow(Salesforce::Records::Lead).to receive(:find).with('L2').and_return(foreign_lead) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(matching_lead) + result = described_class.lead_for(user) + expect(result.lead).to eq(matching_lead) + expect(result.matched_by).to eq(:uuid) + expect(result.rejected).to include(:stored_id_owned_by_other_user) + expect(SecurityLog.where(event_type: 'salesforce_lookup_stored_id_disowned', user: user)).to exist + end + end + + context 'when stored id raises (missing in SF)' do + it 'falls through to uuid match' do + user.update!(salesforce_lead_id: 'L_MISSING') + allow(Salesforce::Records::Lead).to receive(:find).with('L_MISSING').and_raise(StandardError, 'gone') + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(matching_lead) + result = described_class.lead_for(user) + expect(result.matched_by).to eq(:uuid) + end + end + + context 'when only email matches but its UUID is foreign' do + it 'rejects the email match' do + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'e@example.com' }).and_return(foreign_lead) + result = described_class.lead_for(user) + expect(result.lead).to be_nil + expect(result.rejected).to include(:email_match_uuid_conflict) + end + end + + context 'when email matches and is adoptable (UUID blank)' do + it 'matches by email' do + adoptable = Salesforce::Records::Lead.new(id: 'L_OLD', accounts_uuid: nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'e@example.com' }).and_return(adoptable) + result = described_class.lead_for(user) + expect(result.lead).to eq(adoptable) + expect(result.matched_by).to eq(:email) + end + end + + context 'when nothing matches' do + it 'returns nil lead with matched_by nil' do + allow(Salesforce::Records::Lead).to receive(:find_by).and_return(nil) + result = described_class.lead_for(user) + expect(result.lead).to be_nil + expect(result.matched_by).to be_nil + end + end + end + + describe '.contact_for' do + it 'returns the stored Contact when it owns the user' do + user.update!(salesforce_contact_id: 'C1') + contact = Salesforce::Records::Contact.new(id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find).with('C1').and_return(contact) + expect(described_class.contact_for(user)).to eq(contact) + end + + it 'falls through to UUID lookup when stored id is disowned' do + user.update!(salesforce_contact_id: 'C_FOREIGN') + foreign = Salesforce::Records::Contact.new(id: 'C_FOREIGN', accounts_uuid: 'OTHER', master_record_id: nil, is_deleted: false) + uuid_match = Salesforce::Records::Contact.new(id: 'C_OK', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find).with('C_FOREIGN').and_return(foreign) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(uuid_match) + expect(described_class.contact_for(user)).to eq(uuid_match) + end + + it 'returns nil when nothing owns the user' do + allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + expect(described_class.contact_for(user)).to be_nil + end + end +end diff --git a/spec/services/salesforce/metrics_spec.rb b/spec/services/salesforce/metrics_spec.rb new file mode 100644 index 0000000000..e437561fac --- /dev/null +++ b/spec/services/salesforce/metrics_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Metrics do + before do + allow(Sentry).to receive(:capture_check_in).and_return('check_in_id') + allow(Sentry).to receive(:capture_message) + end + + describe '#increment' do + it 'accumulates integer counters' do + m = described_class.new(run: 'sync_contacts') + m.increment(:users_updated) + m.increment(:users_updated, by: 2) + expect(m.counters[:users_updated]).to eq(3) + end + + it 'labels sub-counters by keyword and tracks a total' do + m = described_class.new(run: 'sync_contacts') + m.increment(:contact_id_swaps, reason: :merged) + m.increment(:contact_id_swaps, reason: :merged) + m.increment(:contact_id_swaps, reason: :gone) + expect(m.counters[:contact_id_swaps]).to eq(total: 3, merged: 2, gone: 1) + end + end + + describe '#start! and #emit' do + it 'opens and closes a Sentry check-in when a slug is given' do + m = described_class.new(run: 'sync_contacts', slug: 'update-user-contact-info') + expect(Sentry).to receive(:capture_check_in).with('update-user-contact-info', :in_progress) + m.start! + expect(Sentry).to receive(:capture_check_in).with('update-user-contact-info', :ok, hash_including(:check_in_id)) + m.emit(status: :ok) + end + + it 'skips Sentry check-in when no slug' do + m = described_class.new(run: 'sync_contacts') + expect(Sentry).not_to receive(:capture_check_in) + m.start! + m.emit + end + + it 'returns the payload from emit' do + m = described_class.new(run: 'sync_contacts') + m.increment(:users_updated, by: 5) + payload = m.emit(status: :ok) + expect(payload).to include(run: 'sync_contacts', status: :ok) + expect(payload[:counters][:users_updated]).to eq(5) + expect(payload[:duration_s]).to be_a(Integer) + end + end + + describe '#alert!' do + it 'fires a Sentry message tagged with salesforce-alert' do + m = described_class.new(run: 'sync_contacts') + m.alert!(:contact_id_swap_rate_high, value: 12.0, threshold: 5.0) + expect(Sentry).to have_received(:capture_message) + .with(/contact_id_swap_rate_high/, hash_including(tags: hash_including('salesforce-alert' => 'contact_id_swap_rate_high'))) + end + end +end diff --git a/spec/services/salesforce/reconcile_spec.rb b/spec/services/salesforce/reconcile_spec.rb new file mode 100644 index 0000000000..142f200012 --- /dev/null +++ b/spec/services/salesforce/reconcile_spec.rb @@ -0,0 +1,164 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Reconcile do + before do + Settings::FeatureFlags.salesforce_reconcile_self_heal = true + stub_sentry + end + + after { Settings::FeatureFlags.salesforce_reconcile_self_heal = false } + + # --- Pass 1: contact-anchored --- + + describe 'Pass 1' do + let!(:user) { FactoryBot.create(:user, salesforce_contact_id: 'C1') } + + it 'no-ops when stored contact is live and owns the user' do + contact = Salesforce::Records::Contact.new( + id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({ 'C1' => contact }) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + expect(SecurityLog.where(event_type: 'salesforce_reconcile_user_ok', user: user)).to exist + end + + it 'follows a merge to the master record when the master owns the user' do + merged = Salesforce::Records::Contact.new( + id: 'C1', accounts_uuid: user.uuid, master_record_id: 'NEW', is_deleted: false + ) + master = Salesforce::Records::Contact.new( + id: 'NEW', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({ 'C1' => merged }) + allow(Salesforce::Records::Contact).to receive(:find).with('NEW').and_return(master) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('NEW') + expect(SecurityLog.where(event_type: 'salesforce_reconcile_followed_merge', user: user)).to exist + end + + it 'clears + reattaches via Lookup when stored contact is deleted' do + allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({}) + allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to be_nil + expect(SalesforceDriftFinding.where(user: user, category: 'sf_contact_uuid_mismatch')).to exist + end + + it 'with self_heal flag off, opens finding but does not mutate the user' do + Settings::FeatureFlags.salesforce_reconcile_self_heal = false + allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({}) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + expect(SalesforceDriftFinding.where(user: user, category: 'sf_contact_uuid_mismatch')).to exist + end + end + + # --- Pass 2: lead-anchored --- + + describe 'Pass 2' do + let!(:user) { FactoryBot.create(:user, salesforce_contact_id: nil, salesforce_lead_id: 'L1') } + + it "attaches the converted lead's Contact when it owns the user" do + lead = Salesforce::Records::Lead.new( + id: 'L1', accounts_uuid: user.uuid, is_converted: true, converted_contact_id: 'CC1' + ) + contact = Salesforce::Records::Contact.new( + id: 'CC1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:fetch_leads_by_id).and_return({ 'L1' => lead }) + allow(Salesforce::Records::Contact).to receive(:find).with('CC1').and_return(contact) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('CC1') + expect(SecurityLog.where(event_type: 'salesforce_reconcile_attached_from_conversion', user: user)).to exist + end + + it 'opens a finding when the stored lead is gone' do + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:fetch_leads_by_id).and_return({}) + allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(SalesforceDriftFinding.where(user: user, category: 'sf_lead_uuid_mismatch')).to exist + end + end + + # --- Pass 3 --- + + describe 'Pass 3' do + let!(:user3) do + FactoryBot.create(:user, + salesforce_contact_id: nil, salesforce_lead_id: nil, + is_profile_complete: true, role: 'instructor', faculty_status: 'pending_faculty') + end + + it 'attaches a Contact found by accounts_uuid' do + contact = Salesforce::Records::Contact.new( + id: 'C3', accounts_uuid: user3.uuid, master_record_id: nil, is_deleted: false + ) + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow(Salesforce::Records::Contact).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([contact]) + allow(Salesforce::Records::Lead).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([]) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user3.reload.salesforce_contact_id).to eq('C3') + expect(SecurityLog.where(event_type: 'salesforce_link_restored_by_reconcile', user: user3)).to exist + end + + it 'opens user_unlinked_eligible finding when neither found' do + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow(Salesforce::Records::Contact).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([]) + allow(Salesforce::Records::Lead).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([]) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(SalesforceDriftFinding.where(user: user3, category: 'user_unlinked_eligible')).to exist + end + end + + # --- Finalize --- + + describe 'finalize_findings' do + it 'closes open findings not refreshed during this run' do + stale = FactoryBot.create(:salesforce_drift_finding, + category: 'sf_orphan_contact', last_seen_at: 2.days.ago, resolved_at: nil) + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(stale.reload.resolved_at).not_to be_nil + end + + it 'fires drift_findings_total_open alert when threshold exceeded' do + Settings::Db.store.salesforce_alert_drift_open_total = 0 + # Create with last_seen_at in the future so finalize doesn't auto-close it + FactoryBot.create(:salesforce_drift_finding, resolved_at: nil, last_seen_at: 1.hour.from_now) + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + expect(Sentry).to receive(:capture_message).with(/drift_findings_total_open/, anything) + described_class.call + ensure + Settings::Db.store.salesforce_alert_drift_open_total = 100 + end + end +end diff --git a/spec/services/salesforce/records/base_spec.rb b/spec/services/salesforce/records/base_spec.rb new file mode 100644 index 0000000000..85a9fdec3d --- /dev/null +++ b/spec/services/salesforce/records/base_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Records::Base do + it 'aliases to ActiveForce::SObject' do + expect(Salesforce::Records::Base).to eq(ActiveForce::SObject) + end + + describe '.find_or_initialize_by' do + let(:subclass) do + Class.new(Salesforce::Records::Base) do + self.table_name = 'Dummy' + end + end + + it 'returns an existing record when found' do + existing = subclass.new(id: 'X') + allow(subclass).to receive(:find_by).with({ id: 'X' }).and_return(existing) + expect(subclass.find_or_initialize_by(id: 'X')).to eq(existing) + end + + it 'returns a new instance of the subclass when find_by returns nil' do + allow(subclass).to receive(:find_by).with({ id: 'Y' }).and_return(nil) + record = subclass.find_or_initialize_by(id: 'Y') + expect(record).to be_a(subclass) + expect(record.id).to eq('Y') + end + end + + describe '#save_if_changed' do + let(:subclass) do + Class.new(Salesforce::Records::Base) do + self.table_name = 'Dummy' + end + end + + it 'is exposed on instances' do + expect(subclass.new).to respond_to(:save_if_changed) + end + end +end diff --git a/spec/services/salesforce/records/contact_spec.rb b/spec/services/salesforce/records/contact_spec.rb new file mode 100644 index 0000000000..3b8ec56f9f --- /dev/null +++ b/spec/services/salesforce/records/contact_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Records::Contact do + it 'maps to the Contact SObject' do + expect(described_class.table_name).to eq('Contact') + end + + %i[ + id name first_name last_name email faculty_verified last_modified_at + school_id school_type all_emails adoption_status accounts_uuid lead_source + signup_date assignable_interest assignable_adoption_date + master_record_id is_deleted + ].each do |attr| + it "responds to ##{attr}" do + expect(described_class.new).to respond_to(attr) + end + end +end diff --git a/spec/services/salesforce/records/lead_spec.rb b/spec/services/salesforce/records/lead_spec.rb new file mode 100644 index 0000000000..4c3fcfe9bb --- /dev/null +++ b/spec/services/salesforce/records/lead_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Records::Lead do + it 'maps to the Lead SObject' do + expect(described_class.table_name).to eq('Lead') + end + + %i[ + id name first_name last_name email phone source application_source + role position title subject_interest school city state state_code country + accounts_uuid os_accounts_id verification_status adoption_status adoption_json + num_students who_chooses_books b_r_i_marketing title_1_school newsletter + newsletter_opt_in self_reported_school sheerid_school_name account_id school_id + signup_date tracking_parameters expected_start_semester + is_converted converted_contact_id + ].each do |attr| + it "responds to ##{attr}" do + expect(described_class.new).to respond_to(attr) + end + end +end diff --git a/spec/services/salesforce/records/school_spec.rb b/spec/services/salesforce/records/school_spec.rb new file mode 100644 index 0000000000..2707698d1e --- /dev/null +++ b/spec/services/salesforce/records/school_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Records::School do + it 'maps to the Account SObject' do + expect(described_class.table_name).to eq('Account') + end + + %i[ + id name city state country type school_location sheerid_school_name + is_kip is_child_of_kip total_school_enrollment has_assignable_contacts + ].each do |attr| + it "responds to ##{attr}" do + expect(described_class.new).to respond_to(attr) + end + end +end diff --git a/spec/services/salesforce/resolve_faculty_status_spec.rb b/spec/services/salesforce/resolve_faculty_status_spec.rb new file mode 100644 index 0000000000..617631965b --- /dev/null +++ b/spec/services/salesforce/resolve_faculty_status_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe Salesforce::ResolveFacultyStatus do + let(:user) { FactoryBot.create(:user, faculty_status: :no_faculty_info) } + + describe '.from_signup' do + it 'sets pending_faculty when profile complete and no SheerID record' do + user.update!(is_profile_complete: true, sheerid_verification_id: nil) + described_class.from_signup(user) + expect(user.faculty_status).to eq('pending_faculty') + end + + it 'sets status from SheerID when present' do + user.update!(is_profile_complete: true, sheerid_verification_id: 'V1') + v = SheeridVerification.create!(verification_id: 'V1', current_step: 'success') + allow(SheeridVerification).to receive(:find_by).with(verification_id: 'V1').and_return(v) + allow(v).to receive(:current_step_to_faculty_status).and_return(:confirmed_faculty) + described_class.from_signup(user) + expect(user.faculty_status).to eq('confirmed_faculty') + end + + it 'sets incomplete_signup when profile incomplete' do + user.update!(is_profile_complete: false) + described_class.from_signup(user) + expect(user.faculty_status).to eq('incomplete_signup') + end + end + + describe '.from_contact' do + %w[confirmed_faculty pending_faculty rejected_faculty].each do |protected_status| + it "does not overwrite #{protected_status} with no_faculty_info" do + user.update!(faculty_status: protected_status) + contact = Salesforce::Records::Contact.new(faculty_verified: nil) + described_class.from_contact(user, contact) + expect(user.faculty_status).to eq(protected_status) + end + + it "does not overwrite #{protected_status} with incomplete_signup" do + user.update!(faculty_status: protected_status) + contact = Salesforce::Records::Contact.new(faculty_verified: 'incomplete_signup') + described_class.from_contact(user, contact) + expect(user.faculty_status).to eq(protected_status) + end + end + + it 'does not overwrite confirmed_faculty with pending_faculty' do + user.update!(faculty_status: 'confirmed_faculty') + contact = Salesforce::Records::Contact.new(faculty_verified: 'pending_faculty') + described_class.from_contact(user, contact) + expect(user.faculty_status).to eq('confirmed_faculty') + end + + it 'updates from no_faculty_info to confirmed_faculty' do + user.update!(faculty_status: 'no_faculty_info') + contact = Salesforce::Records::Contact.new(faculty_verified: 'confirmed_faculty') + described_class.from_contact(user, contact) + expect(user.faculty_status).to eq('confirmed_faculty') + end + + it 'raises and captures Sentry on unknown faculty_verified value' do + user.update!(faculty_status: 'no_faculty_info') + contact = Salesforce::Records::Contact.new(faculty_verified: 'weird_value', id: 'C1') + expect(Sentry).to receive(:capture_message) + expect { + described_class.from_contact(user, contact) + }.to raise_error(Salesforce::ResolveFacultyStatus::UnknownFacultyVerifiedError) + end + end +end diff --git a/spec/services/salesforce/sync_contacts_spec.rb b/spec/services/salesforce/sync_contacts_spec.rb new file mode 100644 index 0000000000..6e217895ca --- /dev/null +++ b/spec/services/salesforce/sync_contacts_spec.rb @@ -0,0 +1,106 @@ +require 'rails_helper' + +RSpec.describe Salesforce::SyncContacts do + let!(:school) { FactoryBot.create(:school, salesforce_id: 'SF_S1') } + let!(:user) { FactoryBot.create(:user, salesforce_contact_id: nil) } + + let(:live_contact) do + contact = Salesforce::Records::Contact.new( + id: 'C1', accounts_uuid: user.uuid, + master_record_id: nil, is_deleted: false, + faculty_verified: 'confirmed_faculty', school_type: 'College/University (4)', + adoption_status: 'Not Adopter' + ) + sf_school = Salesforce::Records::School.new( + id: 'SF_S1', school_location: 'Domestic', is_kip: false, is_child_of_kip: false + ) + allow(contact).to receive(:school).and_return(sf_school) + allow(contact).to receive(:school_id).and_return('SF_S1') + contact + end + + let(:merged_contact) do + contact = Salesforce::Records::Contact.new( + id: 'C2', accounts_uuid: user.uuid, + master_record_id: 'CMASTER', is_deleted: false, + faculty_verified: nil, school_type: nil, adoption_status: nil + ) + allow(contact).to receive(:school).and_return(nil) + allow(contact).to receive(:school_id).and_return(nil) + contact + end + + before do + stub_sentry + allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([live_contact]) + end + + it 'first-time link sets salesforce_contact_id' do + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + end + + it 'updates faculty_status from the contact' do + described_class.call + expect(user.reload.faculty_status).to eq('confirmed_faculty') + end + + it 'skips merged contacts entirely' do + allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([merged_contact]) + described_class.call + expect(user.reload.salesforce_contact_id).to be_nil + expect(SecurityLog.where(event_type: 'salesforce_contact_skipped_merged_or_deleted', user: user)).to exist + end + + it 'records a conflict when stored contact still owns the user and a new candidate appears' do + user.update!(salesforce_contact_id: 'OLD') + stored = Salesforce::Records::Contact.new( + id: 'OLD', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(stored) + described_class.call + expect(SecurityLog.where(event_type: 'salesforce_contact_id_conflict', user: user)).to exist + expect(user.reload.salesforce_contact_id).to eq('OLD') + end + + it 'swaps when previous contact has been merged into the new one' do + user.update!(salesforce_contact_id: 'OLD') + stored = Salesforce::Records::Contact.new( + id: 'OLD', accounts_uuid: user.uuid, master_record_id: 'C1', is_deleted: false + ) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(stored) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + expect(SecurityLog.where(event_type: 'salesforce_contact_id_swapped', user: user)).to exist + end + + it 'swaps when previous contact has been deleted (no longer found in SF)' do + user.update!(salesforce_contact_id: 'OLD') + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(nil) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + expect(SecurityLog.where(event_type: 'salesforce_contact_id_swapped', user: user)).to exist + end + + it 'persists the cursor to Settings on each run' do + fixed_time = Time.utc(2026, 5, 22, 12, 0, 0) + allow(Time).to receive(:current).and_return(fixed_time) + described_class.call + expect(Settings::Salesforce.contacts_synced_through).to eq(fixed_time) + ensure + Settings::Db.store.salesforce_contacts_synced_through = nil + end + + it 'records unknown_accounts_uuids when the SF Contact UUID maps to no Accounts user' do + foreign = Salesforce::Records::Contact.new( + id: 'C_FOREIGN', accounts_uuid: 'UNKNOWN', master_record_id: nil, is_deleted: false, + faculty_verified: nil, school_type: nil, adoption_status: nil + ) + allow(foreign).to receive(:school).and_return(nil) + allow(foreign).to receive(:school_id).and_return(nil) + allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([foreign]) + described_class.call + # No SecurityLog write because there's no user — but the metric should be bumped (verified indirectly through alert threshold path) + expect(user.reload.salesforce_contact_id).to be_nil + end +end diff --git a/spec/services/salesforce/upsert_lead_spec.rb b/spec/services/salesforce/upsert_lead_spec.rb new file mode 100644 index 0000000000..07a649cd33 --- /dev/null +++ b/spec/services/salesforce/upsert_lead_spec.rb @@ -0,0 +1,142 @@ +require 'rails_helper' + +RSpec.describe Salesforce::UpsertLead do + let!(:home) { FactoryBot.create(:school, name: 'Find Me A Home', salesforce_id: 'SF_HOME') } + + let(:user) do + FactoryBot.create(:user, + role: 'instructor', state: 'activated', is_newflow: true, + using_openstax_how: 'as_primary', is_profile_complete: true, + self_reported_school: 'Test U', school: home, + faculty_status: 'pending_faculty') + end + + let(:lead) { Salesforce::Records::Lead.new(email: 'x@y.com', accounts_uuid: user.uuid) } + + before do + allow(Sentry).to receive(:capture_message) + allow(Salesforce::Records::School).to receive(:find_by) + .with({ name: 'Find Me A Home' }).and_return(OpenStruct.new(id: 'SF_HOME')) + allow(user).to receive(:best_email_address_for_salesforce).and_return('x@y.com') + allow(User).to receive(:find).and_return(user) + end + + context 'creating a new lead when lookup returns nothing' do + before do + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + end + + it 'persists the new lead id on the user' do + described_class.call(user: user) + expect(user.reload.salesforce_lead_id).to eq('NEW_LEAD') + end + + it 'records the upsert_lead_saved audit event' do + described_class.call(user: user) + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_saved', user: user)).to exist + end + end + + context 'when user.save fails the first time and succeeds on retry' do + before do + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + end + + it 'retries the local save and records a retry audit event' do + call_count = 0 + original_save = user.method(:save) + allow(user).to receive(:save) do + call_count += 1 + call_count >= 2 ? original_save.call : false + end + described_class.call(user: user) + expect(call_count).to be >= 2 + expect(SecurityLog.where(event_type: 'salesforce_lead_id_persist_retry', user: user)).to exist + end + end + + context 'when user.save fails 3 times' do + before do + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + allow_any_instance_of(User).to receive(:save).and_return(false) + allow_any_instance_of(User).to receive(:reload).and_return(user) + end + + it 'logs persist_failed loudly and to Sentry' do + expect(Sentry).to receive(:capture_message).with(/lead_id persist failed/) + described_class.call(user: user) + expect(SecurityLog.where(event_type: 'salesforce_lead_id_persist_failed', user: user)).to exist + end + end + + context 'when user already has a verifying contact' do + it 'returns early without saving a lead' do + user.update!(salesforce_contact_id: 'C1') + owning_contact = Salesforce::Records::Contact.new( + id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(owning_contact) + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + expect(Salesforce::Records::Lead).not_to receive(:new) + described_class.call(user: user) + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_skipped_user_has_contact', user: user)).to exist + end + end + + context 'when user has a stored contact_id that no longer owns them' do + it 'clears it and proceeds to create a lead' do + user.update!(salesforce_contact_id: 'C_STALE') + allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil) + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + described_class.call(user: user) + expect(SecurityLog.where(event_type: 'salesforce_stale_contact_id_cleared', user: user)).to exist + expect(user.reload.salesforce_contact_id).to be_nil + end + end + + context 'when the fallback SF School is not yet cached locally' do + it 'creates a stub local School so the saved Lead still has account_id / school_id' do + # Use a fresh SF id that has no local School row, so we exercise the + # cache-miss branch without fighting the foreign key on the let!(:home) + # School the outer fixture created. + uncached_sf_id = 'SF_UNCACHED_HOME' + expect(School.where(salesforce_id: uncached_sf_id)).to be_empty + user.update!(school: nil) + + # SF says the fallback exists at the uncached id. + allow(Salesforce::Records::School).to receive(:find_by) + .with({ name: 'Find Me A Home' }) + .and_return(OpenStruct.new(id: uncached_sf_id, name: 'Find Me A Home')) + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + + expect { + described_class.call(user: user) + }.to change { School.where(salesforce_id: uncached_sf_id).count }.from(0).to(1) + + expect(user.reload.school&.salesforce_id).to eq(uncached_sf_id) + expect(lead.account_id).to eq(uncached_sf_id) + expect(lead.school_id).to eq(uncached_sf_id) + end + end +end diff --git a/spec/services/salesforce/verify_spec.rb b/spec/services/salesforce/verify_spec.rb new file mode 100644 index 0000000000..0760f1e4a5 --- /dev/null +++ b/spec/services/salesforce/verify_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Verify do + let(:user) { instance_double(User, uuid: 'USER-UUID') } + + describe '.lead_owns_user?' do + it 'true when lead UUID matches' do + lead = Salesforce::Records::Lead.new(accounts_uuid: 'USER-UUID') + expect(described_class.lead_owns_user?(lead, user)).to be(true) + end + + it 'true when lead UUID blank (adoptable)' do + lead = Salesforce::Records::Lead.new(accounts_uuid: nil) + expect(described_class.lead_owns_user?(lead, user)).to be(true) + end + + it 'false when lead UUID belongs to someone else' do + lead = Salesforce::Records::Lead.new(accounts_uuid: 'OTHER-UUID') + expect(described_class.lead_owns_user?(lead, user)).to be(false) + end + + it 'false for nil lead' do + expect(described_class.lead_owns_user?(nil, user)).to be(false) + end + end + + describe '.contact_owns_user?' do + it 'true when contact UUID matches and contact is live' do + contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', master_record_id: nil, is_deleted: false) + expect(described_class.contact_owns_user?(contact, user)).to be(true) + end + + it 'false if contact has been merged' do + contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', master_record_id: 'SOMETHING') + expect(described_class.contact_owns_user?(contact, user)).to be(false) + end + + it 'false if contact is deleted' do + contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', is_deleted: true) + expect(described_class.contact_owns_user?(contact, user)).to be(false) + end + + it 'false when UUID mismatches' do + contact = Salesforce::Records::Contact.new(accounts_uuid: 'OTHER', master_record_id: nil, is_deleted: false) + expect(described_class.contact_owns_user?(contact, user)).to be(false) + end + + it 'false when UUID is blank' do + contact = Salesforce::Records::Contact.new(accounts_uuid: nil, master_record_id: nil, is_deleted: false) + expect(described_class.contact_owns_user?(contact, user)).to be(false) + end + end + + describe '.contact_can_be_replaced?' do + let(:replacement) do + Salesforce::Records::Contact.new(id: 'NEW', accounts_uuid: 'USER-UUID', master_record_id: nil, is_deleted: false) + end + + it ':gone when previous missing in SF' do + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(nil) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:gone) + end + + it ':gone when previous is_deleted' do + prev = Salesforce::Records::Contact.new(id: 'OLD', is_deleted: true) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:gone) + end + + it ':merged when previous master_record_id == replacement id' do + prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: 'NEW', accounts_uuid: 'USER-UUID', is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:merged) + end + + it ':uuid_cleared when previous accounts_uuid is now blank and new owns this user' do + prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: nil, accounts_uuid: nil, is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:uuid_cleared) + end + + it 'false when both are live and own this user' do + prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: nil, accounts_uuid: 'USER-UUID', is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to be(false) + end + end +end diff --git a/spec/services/salesforce_spec.rb b/spec/services/salesforce_spec.rb new file mode 100644 index 0000000000..97548e7162 --- /dev/null +++ b/spec/services/salesforce_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe Salesforce do + describe '.configure' do + it 'yields the configuration object' do + Salesforce.configure do |config| + config.username = 'u' + config.password = 'p' + config.security_token = 't' + config.consumer_key = 'ck' + config.consumer_secret = 'cs' + end + expect(Salesforce.configuration.username).to eq('u') + end + end + + describe Salesforce::Configuration do + it 'defaults api_version' do + expect(described_class.new.api_version).to eq('66.0') + end + + it 'defaults login_domain' do + expect(described_class.new.login_domain).to eq('test.salesforce.com') + end + + it 'raises if required fields missing on validate!' do + expect { described_class.new.validate! }.to raise_error(Salesforce::IllegalState) + end + end +end diff --git a/spec/support/salesforce_spec_helpers.rb b/spec/support/salesforce_spec_helpers.rb index 5911f1118c..a8053e604a 100644 --- a/spec/support/salesforce_spec_helpers.rb +++ b/spec/support/salesforce_spec_helpers.rb @@ -1,16 +1,18 @@ module SalesforceSpecHelpers # Helper method to create a Salesforce contact mock def create_sf_contact(uuid:, faculty_verified:, contact_id: 'SF_CONTACT_001', school_id: 'SF_SCHOOL_001') - contact = OpenStax::Salesforce::Remote::Contact.new( + contact = Salesforce::Records::Contact.new( id: contact_id, accounts_uuid: uuid, faculty_verified: faculty_verified, school_type: 'College/University (4)', - adoption_status: 'Not Adopter' + adoption_status: 'Not Adopter', + master_record_id: nil, + is_deleted: false ) # Mock the school association - sf_school = OpenStax::Salesforce::Remote::School.new( + sf_school = Salesforce::Records::School.new( id: school_id, school_location: 'Domestic', is_kip: false, @@ -22,16 +24,38 @@ def create_sf_contact(uuid:, faculty_verified:, contact_id: 'SF_CONTACT_001', sc contact end - # Helper method to stub the salesforce_contacts method + # Helper method to stub the contact fetch in SyncContacts (used by + # UpdateUserContactInfo, which is now a one-line shim). def stub_salesforce_contacts(contacts) - allow_any_instance_of(UpdateUserContactInfo).to receive(:salesforce_contacts).and_return(contacts) + allow_any_instance_of(Salesforce::SyncContacts).to receive(:fetch_contacts).and_return(contacts) end # Helper method to stub Sentry methods def stub_sentry allow(Sentry).to receive(:capture_check_in).and_return('check_in_id') allow(Sentry).to receive(:capture_message) + allow(Sentry).to receive(:capture_exception) end + + # Replace ActiveForce's sfdc_client with an in-memory no-op so unit tests + # never accidentally talk to the real Salesforce sandbox. Pair with + # `restore_salesforce_records!` if your spec needs to revert. + def stub_salesforce_records! + test_client = Class.new do + def query(*) ; [] ; end + def create(*) ; nil; end + def update(*) ; nil; end + def upsert(*) ; nil; end + def find(*) ; nil; end + def authenticate! ; true; end + end.new + ActiveForce.sfdc_client = test_client + end + + def restore_salesforce_records! + ActiveForce.clear_sfdc_client! + end + end RSpec.configure do |config| diff --git a/spec/whenever_spec.rb b/spec/whenever_spec.rb index 85bcd1315f..26c1ddaaef 100644 --- a/spec/whenever_spec.rb +++ b/spec/whenever_spec.rb @@ -12,8 +12,10 @@ end expect(salesforce_jobs.count).to eq 2 - expect_any_instance_of(UpdateUserContactInfo).to receive(:call) - expect_any_instance_of(UpdateSchoolSalesforceInfo).to receive(:call) + # UpdateUserContactInfo and UpdateSchoolSalesforceInfo became shims + # that expose .call as a class method (delegating to Salesforce::*). + expect(UpdateUserContactInfo).to receive(:call) + expect(UpdateSchoolSalesforceInfo).to receive(:call) # Executes the actual rake tasks to make sure all constants and methods exist: salesforce_jobs.each do |job|