diff --git a/app/jobs/salesforce/role_sync_job.rb b/app/jobs/salesforce/role_sync_job.rb index 2465ad9db..9663b4ac4 100644 --- a/app/jobs/salesforce/role_sync_job.rb +++ b/app/jobs/salesforce/role_sync_job.rb @@ -18,6 +18,15 @@ def perform(role_id:) return if role.student? + # The Contact_Editor_Affiliation__c row uses Salesforce external-ID lookups to resolve + # its parent Editor__c (school) and Contact (user). If either parent has not yet been + # pushed to Salesforce, Heroku Connect rejects the INSERT permanently with a + # "Foreign key external ID ... not found" error and the row is stuck FAILED in the + # mirror. Raising SalesforceRecordNotFound here defers the affiliation write via the + # SalesforceSyncJob retry_on, giving the parent records time to land in Salesforce. + ensure_parent_synced!(Salesforce::School, :editoruuid__c, role.school_id, 'Editor__c') + ensure_parent_synced!(Salesforce::Contact, :pi_accounts_unique_id__c, role.user_id, 'Contact') + sf_role = Salesforce::Role.find_or_initialize_by(affiliation_id__c: role_id) sf_role.attributes = sf_role_attributes(role:) @@ -32,6 +41,13 @@ def perform(role_id:) private + def ensure_parent_synced!(model, external_id_field, external_id, label) + return if model.where(external_id_field => external_id).where.not(sfid: nil).exists? + + raise SalesforceRecordNotFound, + "#{label} not yet synced for #{external_id_field}: #{external_id}" + end + def sf_role_attributes(role:) mapped_attributes(role:).to_h do |sf_field, value| value = truncate_value(sf_field:, value:) if value.is_a?(String) diff --git a/spec/jobs/salesforce/role_sync_job_spec.rb b/spec/jobs/salesforce/role_sync_job_spec.rb index 2f77bd98e..2df328a3b 100644 --- a/spec/jobs/salesforce/role_sync_job_spec.rb +++ b/spec/jobs/salesforce/role_sync_job_spec.rb @@ -6,6 +6,12 @@ subject(:perform_job) { described_class.perform_now(role_id: role.id) } let(:role) { create(:role) } + let!(:sf_school) do + create(:salesforce_school, editoruuid__c: role.school_id, sfid: SecureRandom.alphanumeric(18)) + end + let!(:sf_contact) do + create(:salesforce_contact, pi_accounts_unique_id__c: role.user_id, sfid: SecureRandom.alphanumeric(18)) + end around do |example| ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } @@ -53,6 +59,58 @@ end end + context 'when the parent Editor__c is not yet synced to Salesforce' do + before { sf_school.update!(sfid: nil) } + + it 'retries the job to defer the affiliation write' do + expect { perform_job }.to have_enqueued_job(described_class).with(role_id: role.id) + end + + it 'does not write the affiliation to the mirror' do + perform_job + expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil + end + end + + context 'when there is no Salesforce::School row for the role school' do + before { sf_school.destroy } + + it 'retries the job' do + expect { perform_job }.to have_enqueued_job(described_class).with(role_id: role.id) + end + + it 'does not write the affiliation to the mirror' do + perform_job + expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil + end + end + + context 'when the parent Contact is not yet synced to Salesforce' do + before { sf_contact.update!(sfid: nil) } + + it 'retries the job to defer the affiliation write' do + expect { perform_job }.to have_enqueued_job(described_class).with(role_id: role.id) + end + + it 'does not write the affiliation to the mirror' do + perform_job + expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil + end + end + + context 'when there is no Salesforce::Contact row for the role user' do + before { sf_contact.destroy } + + it 'retries the job' do + expect { perform_job }.to have_enqueued_job(described_class).with(role_id: role.id) + end + + it 'does not write the affiliation to the mirror' do + perform_job + expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil + end + end + context 'when SALESFORCE_ENABLED is false' do around do |example| ClimateControl.modify(SALESFORCE_ENABLED: 'false') do