diff --git a/.env.example b/.env.example index 7d94cf136..fb71d7b5c 100644 --- a/.env.example +++ b/.env.example @@ -57,3 +57,11 @@ RESEED_API_KEY=changeme # Enable immediate onboarding for schools ENABLE_IMMEDIATE_SCHOOL_ONBOARDING=true + +# Salesforce Connect +SALESFORCE_ENABLED=true +SALESFORCE_CONNECT_HOST=salesforce_connect +SALESFORCE_CONNECT_PORT=4101 +SALESFORCE_CONNECT_DB=salesforce_development +SALESFORCE_CONNECT_PASSWORD=password +SALESFORCE_CONNECT_USER=postgres diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb8a5b7e4..b7c9f850d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: contents: read issues: write pull-requests: write + packages: read env: RAILS_ENV: test POSTGRES_DB: choco_cake_test @@ -56,6 +57,11 @@ jobs: ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: deterministic-key ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: derivation-salt EDITOR_ENCRYPTION_KEY: a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0 + SALESFORCE_CONNECT_HOST: 127.0.0.1 + SALESFORCE_CONNECT_PORT: '4101' + SALESFORCE_CONNECT_USER: postgres + SALESFORCE_CONNECT_PASSWORD: password + SALESFORCE_CONNECT_DB: salesforce_test services: postgres: image: postgres:12 @@ -74,6 +80,22 @@ jobs: image: redis:6.2-alpine ports: - 6379:6379 + salesforce_connect: + image: 'ghcr.io/raspberrypifoundation/heroku-connect' + credentials: + username: ${{ github.actor }} + password: ${{ secrets.github_token }} + env: + POSTGRES_DB: salesforce_test + POSTGRES_PASSWORD: password + POSTGRES_USER: postgres + ports: + - 4101:5432 + options: >- + --health-cmd="pg_isready -h 127.0.0.1 -U postgres" + --health-interval=5s + --health-timeout=5s + --health-retries=5 steps: - uses: actions/checkout@v4 diff --git a/app/jobs/salesforce/contact_sync_job.rb b/app/jobs/salesforce/contact_sync_job.rb new file mode 100644 index 000000000..63aaf25a1 --- /dev/null +++ b/app/jobs/salesforce/contact_sync_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Salesforce + class ContactSyncJob < SalesforceSyncJob + MODEL_CLASS = Salesforce::Contact + + def perform(school_id:) + school = ::School.find(school_id) + + sf_contact = Salesforce::Contact.find_by(pi_accounts_unique_id__c: school.creator_id) + raise SalesforceRecordNotFound, "Contact not found for creator_id: #{school.creator_id}" unless sf_contact + + sf_contact.experiencecsagreetouxcontact__c = school.creator_agree_to_ux_contact + sf_contact.save! + end + end +end diff --git a/app/jobs/salesforce/role_sync_job.rb b/app/jobs/salesforce/role_sync_job.rb new file mode 100644 index 000000000..febdfb3c9 --- /dev/null +++ b/app/jobs/salesforce/role_sync_job.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Salesforce + class RoleSyncJob < SalesforceSyncJob + MODEL_CLASS = Salesforce::Role + + FIELD_MAPPINGS = { + affiliation_id__c: :id, + contact__r__pi_accounts_unique_id__c: :user_id, + editor__r__editoruuid__c: :school_id, + roletype__c: :role, + createdat__c: :created_at, + updatedat__c: :updated_at + }.freeze + + def perform(role_id:) + role = ::Role.find(role_id) + + return if role.student? + + sf_role = Salesforce::Role.find_or_initialize_by(affiliation_id__c: role_id) + sf_role.attributes = sf_role_attributes(role:) + sf_role.save! + end + + private + + 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) + + [sf_field, value] + end + end + + def mapped_attributes(role:) + FIELD_MAPPINGS.transform_values do |role_field| + role.send(role_field) + end + end + end +end diff --git a/app/jobs/salesforce/salesforce_sync_job.rb b/app/jobs/salesforce/salesforce_sync_job.rb new file mode 100644 index 000000000..63feffe67 --- /dev/null +++ b/app/jobs/salesforce/salesforce_sync_job.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Salesforce + class SalesforceSyncJob < ApplicationJob + include GoodJob::ActiveJobExtensions::Concurrency + + # Serialise concurrent performs for the same record (same class + record ID) + # to prevent TOCTOU races on find_or_initialize_by + save!, while allowing + # jobs for different records to run fully in parallel. + good_job_control_concurrency_with( + perform_limit: 1, + key: -> { "#{self.class.name}/#{arguments.first.values.first}" } + ) + + class SalesforceRecordNotFound < StandardError + end + + class SkipBecauseSalesforceIsDisabled < StandardError + end + + discard_on SkipBecauseSalesforceIsDisabled + + retry_on SalesforceRecordNotFound, wait: :polynomially_longer, attempts: 10 + + include ActionView::Helpers::SanitizeHelper + + queue_as :salesforce_sync + + before_perform do |_job| + salesforce_enabled = ENV.fetch('SALESFORCE_ENABLED', 'true') == 'true' + raise SkipBecauseSalesforceIsDisabled, 'SALESFORCE_ENABLED is not true.' unless salesforce_enabled + end + + def perform(*) + raise NotImplementedError, 'Subclasses must implement perform' + end + + private + + def truncate_value(sf_field:, value:) + column = self.class::MODEL_CLASS.column_for_attribute(sf_field) + return value if column.limit.nil? + + value.truncate(column.limit, omission: '…') + end + end +end diff --git a/app/jobs/salesforce/school_sync_job.rb b/app/jobs/salesforce/school_sync_job.rb new file mode 100644 index 000000000..890e570cc --- /dev/null +++ b/app/jobs/salesforce/school_sync_job.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Salesforce + class SchoolSyncJob < SalesforceSyncJob + MODEL_CLASS = Salesforce::School + + FIELD_MAPPINGS = { + editoruuid__c: :id, + name: :name, + editorreference__c: :reference, + addressline1__c: :address_line_1, + addressline2__c: :address_line_2, + editormunicipality__c: :municipality, + editoradministrativearea__c: :administrative_area, + postcode__c: :postal_code, + countrycode__c: :country_code, + verifiedat__c: :verified_at, + createdat__c: :created_at, + updatedat__c: :updated_at, + rejectedat__c: :rejected_at, + website__c: :website, + userorigin__c: :user_origin, + districtnamesupplied__c: :district_name, + ncesid__c: :district_nces_id, + schoolrollnumber__c: :school_roll_number + }.freeze + + def perform(school_id:) + school = ::School.find(school_id) + + sf_school = Salesforce::School.find_or_initialize_by(editoruuid__c: school_id) + sf_school.attributes = sf_school_attributes(school:) + sf_school.save! + end + + private + + def sf_school_attributes(school:) + mapped_attributes(school:).to_h do |sf_field, value| + value = 'for_education' if sf_field == :userorigin__c && value.nil? + value = truncate_value(sf_field:, value:) if value.is_a?(String) + + [sf_field, value] + end + end + + def mapped_attributes(school:) + FIELD_MAPPINGS.transform_values do |school_field| + school.send(school_field) + end + end + end +end diff --git a/app/models/role.rb b/app/models/role.rb index 46d30bf91..e7da0e617 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -16,6 +16,8 @@ class Role < ApplicationRecord } ) + after_commit :do_salesforce_sync, on: %i[create update] + private def students_cannot_have_additional_roles @@ -38,4 +40,8 @@ def users_can_only_have_roles_in_one_school errors.add(:base, 'Cannot create role as this user already has a role in a different school') end + + def do_salesforce_sync + Salesforce::RoleSyncJob.perform_later(role_id: id) + end end diff --git a/app/models/salesforce/base.rb b/app/models/salesforce/base.rb new file mode 100644 index 000000000..3c64b3bc4 --- /dev/null +++ b/app/models/salesforce/base.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Salesforce + class Base < ApplicationRecord + self.abstract_class = true + + connects_to database: { writing: :salesforce_connect } + end +end diff --git a/app/models/salesforce/contact.rb b/app/models/salesforce/contact.rb new file mode 100644 index 000000000..a9341acbd --- /dev/null +++ b/app/models/salesforce/contact.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Salesforce + class Contact < Salesforce::Base + self.table_name = 'salesforce.contact' + self.primary_key = :pi_accounts_unique_id__c + end +end diff --git a/app/models/salesforce/role.rb b/app/models/salesforce/role.rb new file mode 100644 index 000000000..5e0d4c768 --- /dev/null +++ b/app/models/salesforce/role.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Salesforce + class Role < Salesforce::Base + self.table_name = 'salesforce.contact_editor_affiliation__c' + self.primary_key = :affiliation_id__c + end +end diff --git a/app/models/salesforce/school.rb b/app/models/salesforce/school.rb new file mode 100644 index 000000000..de05751c7 --- /dev/null +++ b/app/models/salesforce/school.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Salesforce + class School < Salesforce::Base + self.table_name = 'salesforce.editor__c' + self.primary_key = :editoruuid__c + end +end diff --git a/app/models/school.rb b/app/models/school.rb index 2f14500b0..f7b1546c2 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -52,6 +52,8 @@ class School < ApplicationRecord # TODO: Remove the conditional once the feature flag is retired after_create :generate_code!, if: -> { FeatureFlags.immediate_school_onboarding? } + after_commit :do_salesforce_sync, on: %i[create update] + def self.find_for_user!(user) school = Role.find_by(user_id: user.id)&.school || find_by(creator_id: user.id) raise ActiveRecord::RecordNotFound unless school @@ -169,4 +171,9 @@ def format_uk_postal_code # ensures UK postcodes are always formatted correctly (as the inward code is always 3 chars long) self.postal_code = "#{cleaned_postal_code[0..-4]} #{cleaned_postal_code[-3..]}" end + + def do_salesforce_sync + Salesforce::SchoolSyncJob.perform_later(school_id: id) + Salesforce::ContactSyncJob.perform_later(school_id: id) + end end diff --git a/config/database.yml b/config/database.yml index c4ddd41f6..4a9b66e4b 100644 --- a/config/database.yml +++ b/config/database.yml @@ -7,14 +7,36 @@ default: &default password: <%= ENV.fetch('POSTGRES_PASSWORD', '') %> pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) %> +salesforce_connect: &salesforce_connect + adapter: postgresql + encoding: unicode + host: <%= ENV.fetch('SALESFORCE_CONNECT_HOST', 'localhost') %> + port: <%= ENV.fetch('SALESFORCE_CONNECT_PORT', '5432') %> + username: <%= ENV.fetch('SALESFORCE_CONNECT_USER', '') %> + password: <%= ENV.fetch('SALESFORCE_CONNECT_PASSWORD', '') %> + pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) %> + database_tasks: false + development: - <<: *default - database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_development') %> + default: + <<: *default + database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_development') %> + salesforce_connect: + <<: *salesforce_connect + database: <%= ENV.fetch('SALESFORCE_CONNECT_DB', 'salesforce_development') %> test: - <<: *default - database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_test') %> + default: + <<: *default + database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_test') %> + salesforce_connect: + <<: *salesforce_connect + database: <%= ENV.fetch('SALESFORCE_CONNECT_DB', 'salesforce_test') %> production: - <<: *default - url: <%= ENV['DATABASE_URL'] %> + default: + <<: *default + url: <%= ENV['DATABASE_URL'] %> + salesforce_connect: + <<: *salesforce_connect + url: <%= ENV.fetch('SALESFORCE_CONNECT_URL', "") %> diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 600f199eb..b31cd2561 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -18,5 +18,5 @@ def authenticate_admin # The create_students_job queue is a serial queue that allows only one job at a time. # DO NOT change the value of create_students_job:1 without understanding the implications # of processing more than one user creation job at once. - config.good_job.queues = 'create_students_job:1;import_schools_job:1;default:5' + config.good_job.queues = 'create_students_job:1;import_schools_job:1;salesforce_sync:10;default:5' end diff --git a/docker-compose.yml b/docker-compose.yml index 99541f0fd..4117cb24d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,8 @@ services: depends_on: db: condition: service_healthy + salesforce_connect: + condition: service_healthy volumes: - .:/app - bundle-data-v2:/usr/local/bundle @@ -46,6 +48,10 @@ services: - POSTGRES_DB - POSTGRES_PASSWORD - POSTGRES_USER + - SALESFORCE_CONNECT_HOST=salesforce_connect + - SALESFORCE_CONNECT_USER=postgres + - SALESFORCE_CONNECT_PASSWORD=password + - SALESFORCE_CONNECT_DB=salesforce_development extra_hosts: - "host.docker.internal:host-gateway" smee: @@ -53,7 +59,29 @@ services: platform: linux/amd64 command: -u $SMEE_TUNNEL -t http://api:3009/github_webhooks + salesforce_connect: + image: ghcr.io/raspberrypifoundation/heroku-connect + volumes: + - salesforce_connect_data:/var/lib/postgres/data/ + environment: + - POSTGRES_DB=salesforce_development + - POSTGRES_CLONE_DB=salesforce_test + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}", + ] + interval: 5s + timeout: 5s + retries: 10 + ports: + - "4101:5432" + volumes: postgres-data: bundle-data-v2: node_modules: + salesforce_connect_data: diff --git a/lib/tasks/salesforce_sync.rake b/lib/tasks/salesforce_sync.rake new file mode 100644 index 000000000..99e110bb6 --- /dev/null +++ b/lib/tasks/salesforce_sync.rake @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +namespace :salesforce_sync do + desc 'Sync all Schools to Salesforce' + task school: :environment do + School.pluck(:id).each do |school_id| + Salesforce::SchoolSyncJob.perform_later(school_id:) + end + end + + desc 'Sync all Roles to Salesforce' + task role: :environment do + Role.pluck(:id).each do |role_id| + Salesforce::RoleSyncJob.perform_later(role_id:) + end + end + + desc 'Sync creator_agree_to_ux_contact for all Schools to Salesforce Contact' + task contact: :environment do + School.pluck(:id).each do |school_id| + Salesforce::ContactSyncJob.perform_later(school_id:) + end + end +end diff --git a/spec/factories/salesforce/contact.rb b/spec/factories/salesforce/contact.rb new file mode 100644 index 000000000..0bf1e3ec8 --- /dev/null +++ b/spec/factories/salesforce/contact.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:salesforce_contact, class: 'Salesforce::Contact') do + pi_accounts_unique_id__c { SecureRandom.uuid } + end +end diff --git a/spec/factories/salesforce/role.rb b/spec/factories/salesforce/role.rb new file mode 100644 index 000000000..7cbe7f685 --- /dev/null +++ b/spec/factories/salesforce/role.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:salesforce_role, class: 'Salesforce::Role') do + affiliation_id__c { SecureRandom.uuid } + end +end diff --git a/spec/factories/salesforce/school.rb b/spec/factories/salesforce/school.rb new file mode 100644 index 000000000..1aee06354 --- /dev/null +++ b/spec/factories/salesforce/school.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:salesforce_school, class: 'Salesforce::School') do + editoruuid__c { SecureRandom.uuid } + end +end diff --git a/spec/jobs/salesforce/contact_sync_job_spec.rb b/spec/jobs/salesforce/contact_sync_job_spec.rb new file mode 100644 index 000000000..0fa740714 --- /dev/null +++ b/spec/jobs/salesforce/contact_sync_job_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Salesforce::ContactSyncJob do + subject(:perform_job) { described_class.perform_now(school_id: school.id) } + + let(:school) { create(:school, creator_agree_to_ux_contact: true) } + let!(:sf_contact) { create(:salesforce_contact, pi_accounts_unique_id__c: school.creator_id) } + + it 'sets experiencecsagreetouxcontact__c from school.creator_agree_to_ux_contact' do + perform_job + expect(sf_contact.reload.experiencecsagreetouxcontact__c).to be(true) + end + + it 'saves the contact' do + expect { perform_job }.not_to raise_error + end + + context 'when the Contact is not found in Salesforce' do + before { sf_contact.destroy } + + it 'retries the job' do + expect { perform_job }.to have_enqueued_job(described_class).with(school_id: school.id) + end + end + + context 'when the Salesforce contact fails to save' do + let(:sf_contact_double) { instance_double(Salesforce::Contact) } + + before do + allow(Salesforce::Contact).to receive(:find_by) + .with(pi_accounts_unique_id__c: school.creator_id) + .and_return(sf_contact_double) + allow(sf_contact_double).to receive(:experiencecsagreetouxcontact__c=) + allow(sf_contact_double).to receive(:save!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises an error' do + expect { perform_job }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'when SALESFORCE_ENABLED is false' do + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'false') do + example.run + end + end + + it 'discards the job without syncing' do + sf_contact.update!(experiencecsagreetouxcontact__c: false) + perform_job + expect(sf_contact.reload.experiencecsagreetouxcontact__c).to be(false) + end + end +end diff --git a/spec/jobs/salesforce/role_sync_job_spec.rb b/spec/jobs/salesforce/role_sync_job_spec.rb new file mode 100644 index 000000000..3b448d231 --- /dev/null +++ b/spec/jobs/salesforce/role_sync_job_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Salesforce::RoleSyncJob do + subject(:perform_job) { described_class.perform_now(role_id: role.id) } + + let(:role) { create(:role) } + + context 'when the job has run' do + before { perform_job } + + it 'syncs all FIELD_MAPPINGS to the correct role values' do + sf_role = Salesforce::Role.find_by(affiliation_id__c: role.id) + described_class::FIELD_MAPPINGS.each do |sf_field, role_field| + expected = Salesforce::Role.type_for_attribute(sf_field).cast(role.send(role_field)) + expect(sf_role.send(sf_field)).to eq(expected), + "Expected #{sf_field} to equal role.#{role_field}" + end + end + end + + context 'when the Salesforce role fails to save' do + let(:sf_role) { instance_double(Salesforce::Role) } + + before do + allow(Salesforce::Role).to receive(:find_or_initialize_by).with(affiliation_id__c: role.id).and_return(sf_role) + allow(sf_role).to receive(:attributes=) + allow(sf_role).to receive(:save!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises an error' do + expect { perform_job }.to raise_error ActiveRecord::RecordInvalid + end + end + + context 'when the role is a student role' do + let(:role) { create(:student_role) } + + it 'does not create a Salesforce role record' 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 + example.run + end + end + + it 'discards the job without syncing' do + perform_job + expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil + end + end +end diff --git a/spec/jobs/salesforce/school_sync_job_spec.rb b/spec/jobs/salesforce/school_sync_job_spec.rb new file mode 100644 index 000000000..87ce00ef6 --- /dev/null +++ b/spec/jobs/salesforce/school_sync_job_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Salesforce::SchoolSyncJob do + subject(:perform_job) { described_class.perform_now(school_id: school.id) } + + let(:school) { create(:school) } + + context 'when the job has run' do + before { perform_job } + + it 'syncs all FIELD_MAPPINGS to the correct school values' do + sf_school = Salesforce::School.find_by(editoruuid__c: school.id) + described_class::FIELD_MAPPINGS.each do |sf_field, school_field| + expected = Salesforce::School.type_for_attribute(sf_field).cast(school.send(school_field)) + expect(sf_school.send(sf_field)).to eq(expected), + "Expected #{sf_field} to equal school.#{school_field}" + end + end + + context 'when an address field is very long' do + let(:school) { create(:school, address_line_1: '❌' * 300) } + + it 'truncates addressline1__c' do + sf_school = Salesforce::School.find_by(editoruuid__c: school.id) + expect(sf_school.addressline1__c).to end_with('…') + expect(sf_school.addressline1__c.length).to be < school.address_line_1.length + end + end + + context 'when the school is verified' do + let(:school) { create(:verified_school) } + + it 'syncs verifiedat__c' do + sf_school = Salesforce::School.find_by(editoruuid__c: school.id) + expect(sf_school.verifiedat__c).to eq(school.verified_at) + end + end + + context 'when the school is rejected' do + let(:school) { create(:school, rejected_at: Time.current) } + + it 'syncs rejectedat__c' do + sf_school = Salesforce::School.find_by(editoruuid__c: school.id) + expect(sf_school.rejectedat__c).to eq(school.rejected_at) + end + end + end + + context 'when the Salesforce school fails to save' do + let(:sf_school) { instance_double(Salesforce::School) } + + before do + allow(Salesforce::School).to receive(:find_or_initialize_by).with(editoruuid__c: school.id).and_return(sf_school) + allow(sf_school).to receive(:attributes=) + allow(sf_school).to receive(:save!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises an error' do + expect { perform_job }.to raise_error ActiveRecord::RecordInvalid + end + end + + context 'when SALESFORCE_ENABLED is false' do + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'false') do + example.run + end + end + + it 'discards the job without syncing' do + perform_job + expect(Salesforce::School.find_by(editoruuid__c: school.id)).to be_nil + end + end +end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb index a9f1fd8cc..c53a62ae6 100644 --- a/spec/models/role_spec.rb +++ b/spec/models/role_spec.rb @@ -165,4 +165,15 @@ end end end + + describe 'salesforce sync' do + it 'enqueues a Salesforce::RoleSyncJob on create' do + expect { create(:role) }.to have_enqueued_job(Salesforce::RoleSyncJob) + end + + it 'enqueues a Salesforce::RoleSyncJob on update' do + role = create(:role) + expect { role.update!(role: 'teacher') }.to have_enqueued_job(Salesforce::RoleSyncJob) + end + end end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 06846060c..808d24fea 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -632,6 +632,26 @@ end end + describe 'salesforce sync' do + it 'enqueues Salesforce::SchoolSyncJob on create' do + expect { create(:school) }.to have_enqueued_job(Salesforce::SchoolSyncJob) + end + + it 'enqueues Salesforce::ContactSyncJob on create' do + expect { create(:school) }.to have_enqueued_job(Salesforce::ContactSyncJob) + end + + it 'enqueues Salesforce::SchoolSyncJob on update' do + school = create(:school) + expect { school.update!(name: 'Updated Name') }.to have_enqueued_job(Salesforce::SchoolSyncJob) + end + + it 'enqueues Salesforce::ContactSyncJob on update' do + school = create(:school) + expect { school.update!(name: 'Updated Name') }.to have_enqueued_job(Salesforce::ContactSyncJob) + end + end + describe '#reopen' do it 'sets rejected_at to nil' do school.reject