diff --git a/AGENTS.md b/AGENTS.md index b53622f7a..9358fcb0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,13 @@ docker compose up - Single spec: `docker compose run --rm api rspec spec/path/to/spec.rb` - Lint: `docker compose run --rm api bundle exec rubocop` - CI: GitHub Actions with Ruby 4, Postgres 12, Redis. +- Salesforce sync specs need `SALESFORCE_CONNECT_DB` set and matching Heroku Connect tables (schema comes from the published `heroku-connect` image after Salesforce mapping is exported). + +## Salesforce / Heroku Connect +- Sync writes to the `salesforce_connect` DB (not a Salesforce API). Pattern from editor-api PR #677. +- Feature flag: `SALESFORCE_ENABLED=true`. +- After deploy, backfill: `rails salesforce_sync:school`, `salesforce_sync:role`, `salesforce_sync:contact`, `salesforce_sync:school_class`, `salesforce_sync:class_teacher`, `salesforce_sync:lesson`. +- **Parent-sync race guard (required for any job using `__r__` external-ID lookups).** Heroku Connect rejects an INSERT permanently with `Foreign key external ID … not found` if the parent record isn't yet in Salesforce — the mirror row stays `FAILED` forever (no auto-retry). Call `ensure_parent_synced!(model, external_id_field, external_id, label)` on `Salesforce::SalesforceSyncJob` (the base class) before saving a child record; it checks the parent has a non-nil `sfid` in its Heroku Connect mirror and raises `SalesforceRecordNotFound` if not. The base job declares `retry_on SalesforceRecordNotFound, wait: :polynomially_longer, attempts: 10` so the job self-heals once parents land. See `Salesforce::RoleSyncJob` and `Salesforce::ClassTeacherSyncJob` for call-site examples. ## Where to Look First - Routes: `config/routes.rb`. Auth: `config/initializers/omniauth.rb`, `app/helpers/authentication_helper.rb`, `app/controllers/concerns/identifiable.rb`. diff --git a/app/jobs/salesforce/class_teacher_sync_job.rb b/app/jobs/salesforce/class_teacher_sync_job.rb new file mode 100644 index 000000000..cd7f55a40 --- /dev/null +++ b/app/jobs/salesforce/class_teacher_sync_job.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Salesforce + class ClassTeacherSyncJob < SalesforceSyncJob + MODEL_CLASS = Salesforce::ClassTeacher + + FIELD_MAPPINGS = { + contactclassroomaffiliationuuid__c: :id, + classroom__r__classroomuuid__c: :school_class_id, + contact_teacher__r__pi_accounts_unique_id__c: :teacher_id, + createdat__c: :created_at, + updatedat__c: :updated_at + }.freeze + + def perform(class_teacher_id:) + class_teacher = ::ClassTeacher.find(class_teacher_id) + + ensure_parent_synced!(Salesforce::SchoolClass, :classroomuuid__c, class_teacher.school_class_id, 'Classroom__c') + ensure_parent_synced!(Salesforce::Contact, :pi_accounts_unique_id__c, class_teacher.teacher_id, 'Contact') + + sf_class_teacher = Salesforce::ClassTeacher.find_or_initialize_by( + contactclassroomaffiliationuuid__c: class_teacher_id + ) + sf_class_teacher.attributes = sf_class_teacher_attributes(class_teacher:) + + sf_class_teacher.save! + end + + private + + def sf_class_teacher_attributes(class_teacher:) + mapped_attributes(class_teacher:).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(class_teacher:) + FIELD_MAPPINGS.transform_values do |class_teacher_field| + class_teacher.send(class_teacher_field) + end + end + + def concurrency_key_id = arguments.first.with_indifferent_access[:class_teacher_id] + end +end diff --git a/app/jobs/salesforce/lesson_sync_job.rb b/app/jobs/salesforce/lesson_sync_job.rb new file mode 100644 index 000000000..84f9dce83 --- /dev/null +++ b/app/jobs/salesforce/lesson_sync_job.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Salesforce + class LessonSyncJob < SalesforceSyncJob + MODEL_CLASS = Salesforce::Lesson + + FIELD_MAPPINGS = { + lesson_uuid__c: :id, + classroom__r__classroomuuid__c: :school_class_id, + lessontitle__c: :name, + createdat__c: :created_at, + updatedat__c: :updated_at + }.freeze + + def perform(lesson_id:) + lesson = ::Lesson.find(lesson_id) + return if lesson.school_class_id.blank? + + ensure_parent_synced!(Salesforce::SchoolClass, :classroomuuid__c, lesson.school_class_id, 'Classroom__c') + + sf_lesson = Salesforce::Lesson.find_or_initialize_by(lesson_uuid__c: lesson_id) + sf_lesson.attributes = sf_lesson_attributes(lesson:) + + sf_lesson.save! + end + + private + + def sf_lesson_attributes(lesson:) + mapped_attributes(lesson:).merge( + teacherprojecttitle__c: lesson.project&.name, + teacherprojecttype__c: lesson.project&.project_type, + numberofassignedprojects__c: lesson.remixes.count, + numberofcompletedprojects__c: lesson.submitted_projects_count, + lastsyncdate__c: Time.current + ).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(lesson:) + FIELD_MAPPINGS.transform_values do |lesson_field| + lesson.send(lesson_field) + end + end + + def concurrency_key_id = arguments.first.with_indifferent_access[:lesson_id] + end +end diff --git a/app/jobs/salesforce/role_sync_job.rb b/app/jobs/salesforce/role_sync_job.rb index 9663b4ac4..4ffa5503a 100644 --- a/app/jobs/salesforce/role_sync_job.rb +++ b/app/jobs/salesforce/role_sync_job.rb @@ -18,12 +18,6 @@ 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') @@ -41,13 +35,6 @@ 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/app/jobs/salesforce/salesforce_sync_job.rb b/app/jobs/salesforce/salesforce_sync_job.rb index b7c5854f4..6231b1d30 100644 --- a/app/jobs/salesforce/salesforce_sync_job.rb +++ b/app/jobs/salesforce/salesforce_sync_job.rb @@ -39,6 +39,19 @@ def concurrency_key_id raise NotImplementedError, "#{self.class.name} must implement concurrency_key_id" end + # Guard a write that resolves a Salesforce parent via an external-ID lookup + # (`__r__`). Heroku Connect rejects the INSERT permanently with + # "Foreign key external ID ... not found" if the parent isn't yet in Salesforce, and + # the mirror row stays FAILED forever (no auto-retry). Raising SalesforceRecordNotFound + # here defers the write via the retry_on declared on this base class, so the job + # self-heals once the parent lands. + 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 truncate_value(sf_field:, value:) column = self.class::MODEL_CLASS.column_for_attribute(sf_field) return value if column.limit.nil? diff --git a/app/jobs/salesforce/school_class_sync_job.rb b/app/jobs/salesforce/school_class_sync_job.rb new file mode 100644 index 000000000..dc2bb5c91 --- /dev/null +++ b/app/jobs/salesforce/school_class_sync_job.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Salesforce + class SchoolClassSyncJob < SalesforceSyncJob + MODEL_CLASS = Salesforce::SchoolClass + + FIELD_MAPPINGS = { + classroomuuid__c: :id, + editor__r__editoruuid__c: :school_id, + classroomtitle__c: :name, + createdat__c: :created_at, + updatedat__c: :updated_at + }.freeze + + def perform(school_class_id:) + school_class = ::SchoolClass.find(school_class_id) + + ensure_parent_synced!(Salesforce::School, :editoruuid__c, school_class.school_id, 'Editor__c') + + sf_school_class = Salesforce::SchoolClass.find_or_initialize_by(classroomuuid__c: school_class_id) + sf_school_class.attributes = sf_school_class_attributes(school_class:) + + sf_school_class.save! + end + + private + + def sf_school_class_attributes(school_class:) + mapped_attributes(school_class:).merge( + numberofmembers__c: school_class.students.count, + lastsyncdate__c: Time.current + ).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(school_class:) + FIELD_MAPPINGS.transform_values do |school_class_field| + school_class.send(school_class_field) + end + end + + def concurrency_key_id = arguments.first.with_indifferent_access[:school_class_id] + end +end diff --git a/app/models/class_student.rb b/app/models/class_student.rb index fd75b3d67..bca5e9b2c 100644 --- a/app/models/class_student.rb +++ b/app/models/class_student.rb @@ -18,12 +18,21 @@ class ClassStudent < ApplicationRecord } ) + after_commit :do_salesforce_sync, on: %i[create destroy], if: -> { FeatureFlags.salesforce_sync? } + def user_id student_id end private + # Roster changes only affect Classroom.numberofmembers__c — Lesson.numberofassignedprojects__c + # is driven by remix Project creation (see Project#enqueue_lesson_sync_for_remix), not by + # students joining or leaving a class. + def do_salesforce_sync + Salesforce::SchoolClassSyncJob.perform_later(school_class_id: school_class_id) + end + def student_has_the_school_student_role_for_the_school return unless student_id_changed? && errors.blank? && student.present? diff --git a/app/models/class_teacher.rb b/app/models/class_teacher.rb index 771ac9fd2..5e2c027d7 100644 --- a/app/models/class_teacher.rb +++ b/app/models/class_teacher.rb @@ -18,12 +18,37 @@ class ClassTeacher < ApplicationRecord } ) + # When a teacher is added or removed from an existing SchoolClass, we refresh the + # classroom mirror (member counts) AND publish the affiliation. On destroy we only + # refresh the classroom; the now-deleted affiliation can't be republished. + # + # When a teacher is created as part of a brand-new SchoolClass (in the same transaction), + # neither callback fires here — the SchoolClass's own after_commit handles the classroom + # sync, and the initial teacher affiliations are not auto-published (backfill via + # `rake salesforce_sync:class_teacher` if needed). Suppressing the duplicates keeps the + # queue tidy and avoids racing the SchoolClassSyncJob. + EXISTING_CLASS_THRESHOLD = 1.second + + after_commit :enqueue_school_class_sync, on: :destroy, if: -> { FeatureFlags.salesforce_sync? } + after_commit :enqueue_syncs_for_existing_class, on: :create, if: lambda { + FeatureFlags.salesforce_sync? && school_class.created_at < EXISTING_CLASS_THRESHOLD.ago + } + def user_id teacher_id end private + def enqueue_school_class_sync + Salesforce::SchoolClassSyncJob.perform_later(school_class_id: school_class_id) + end + + def enqueue_syncs_for_existing_class + enqueue_school_class_sync + Salesforce::ClassTeacherSyncJob.perform_later(class_teacher_id: id) + end + def teacher_has_the_school_teacher_role_for_the_school return unless teacher_id_changed? && errors.blank? && teacher.present? diff --git a/app/models/lesson.rb b/app/models/lesson.rb index d55cdbbda..c0c225521 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -21,6 +21,9 @@ class Lesson < ApplicationRecord validate :user_has_the_school_owner_or_school_teacher_role_for_the_school validate :user_is_the_school_teacher_for_the_school_class + after_commit :do_salesforce_sync, on: %i[create update], + if: -> { FeatureFlags.salesforce_sync? && school_class_id.present? } + def self.users User.from_userinfo(ids: pluck(:user_id)) end @@ -43,6 +46,10 @@ def recalculate_submitted_projects_count! private + def do_salesforce_sync + Salesforce::LessonSyncJob.perform_later(lesson_id: id) + end + def assign_school_from_school_class self.school ||= school_class&.school end diff --git a/app/models/project.rb b/app/models/project.rb index 4bc2812b9..93feeb2bc 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -46,6 +46,14 @@ module Types } ) + # A remix Project is created the first time a student saves work on a lesson, which is + # exactly when `lesson.remixes.count` (Salesforce numberofassignedprojects__c) changes. + # Triggering the sync here keeps that count fresh without scanning every lesson in the + # class on every roster change. + after_commit :enqueue_lesson_sync_for_remix, + on: :create, + if: -> { FeatureFlags.salesforce_sync? && remixed_from_id.present? } + def self.students(school, current_user) SchoolStudent::List.call(school:, token: current_user.token, student_ids: pluck(:user_id).uniq)[:school_students] || [] end @@ -103,6 +111,13 @@ def self_and_ancestors private + def enqueue_lesson_sync_for_remix + lesson_id = parent&.lesson_id + return if lesson_id.blank? + + Salesforce::LessonSyncJob.perform_later(lesson_id:) + end + def check_unique_not_null self.identifier ||= PhraseIdentifier.generate end diff --git a/app/models/salesforce/class_teacher.rb b/app/models/salesforce/class_teacher.rb new file mode 100644 index 000000000..c49842978 --- /dev/null +++ b/app/models/salesforce/class_teacher.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Salesforce + class ClassTeacher < Salesforce::Base + self.table_name = 'salesforce.contact_classroom_affiliation__c' + self.primary_key = :contactclassroomaffiliationuuid__c + end +end diff --git a/app/models/salesforce/lesson.rb b/app/models/salesforce/lesson.rb new file mode 100644 index 000000000..5db6f4d54 --- /dev/null +++ b/app/models/salesforce/lesson.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Salesforce + class Lesson < Salesforce::Base + self.table_name = 'salesforce.lesson__c' + self.primary_key = :lesson_uuid__c + end +end diff --git a/app/models/salesforce/school_class.rb b/app/models/salesforce/school_class.rb new file mode 100644 index 000000000..85d648311 --- /dev/null +++ b/app/models/salesforce/school_class.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Salesforce + class SchoolClass < Salesforce::Base + self.table_name = 'salesforce.classroom__c' + self.primary_key = :classroomuuid__c + end +end diff --git a/app/models/school_class.rb b/app/models/school_class.rb index 51b835a87..a755f4b4b 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -31,6 +31,8 @@ class SchoolClass < ApplicationRecord } ) + after_commit :do_salesforce_sync, on: %i[create update], if: -> { FeatureFlags.salesforce_sync? } + def self.teachers teacher_ids = all.map(&:teacher_ids).flatten.uniq User.from_userinfo(ids: teacher_ids) @@ -87,6 +89,10 @@ def submitted_projects_count private + def do_salesforce_sync + Salesforce::SchoolClassSyncJob.perform_later(school_class_id: id) + end + def school_class_has_at_least_one_teacher return if teachers.present? diff --git a/app/models/school_project.rb b/app/models/school_project.rb index a848e1ae8..1e2a34e2c 100644 --- a/app/models/school_project.rb +++ b/app/models/school_project.rb @@ -31,6 +31,15 @@ def recalculate_lesson_submitted_projects_count!(_transition = nil) lesson&.recalculate_submitted_projects_count! end + def enqueue_salesforce_lesson_sync(_transition = nil) + return unless FeatureFlags.salesforce_sync? + + lesson_id = lesson&.id + return if lesson_id.blank? + + Salesforce::LessonSyncJob.perform_later(lesson_id:) + end + # Add convenience methods for each state def unsubmitted? state_machine.in_state?(:unsubmitted) diff --git a/app/state_machines/school_project_state_machine.rb b/app/state_machines/school_project_state_machine.rb index f7284c801..391d1616e 100644 --- a/app/state_machines/school_project_state_machine.rb +++ b/app/state_machines/school_project_state_machine.rb @@ -17,4 +17,6 @@ class SchoolProjectStateMachine after_transition(to: :submitted, &:recalculate_lesson_submitted_projects_count!) after_transition(from: :submitted, &:recalculate_lesson_submitted_projects_count!) + after_transition(to: %i[submitted complete], &:enqueue_salesforce_lesson_sync) + after_transition(from: :complete, &:enqueue_salesforce_lesson_sync) end diff --git a/lib/tasks/salesforce_sync.rake b/lib/tasks/salesforce_sync.rake index b688e328a..8927c96d9 100644 --- a/lib/tasks/salesforce_sync.rake +++ b/lib/tasks/salesforce_sync.rake @@ -21,4 +21,25 @@ namespace :salesforce_sync do Salesforce::ContactSyncJob.perform_later(school_id: school.id) end end + + desc 'Sync all SchoolClasses to Salesforce' + task school_class: :environment do + SchoolClass.find_each do |school_class| + Salesforce::SchoolClassSyncJob.perform_later(school_class_id: school_class.id) + end + end + + desc 'Sync all ClassTeacher affiliations to Salesforce' + task class_teacher: :environment do + ClassTeacher.find_each do |class_teacher| + Salesforce::ClassTeacherSyncJob.perform_later(class_teacher_id: class_teacher.id) + end + end + + desc 'Sync all classroom Lessons to Salesforce' + task lesson: :environment do + Lesson.where.not(school_class_id: nil).find_each do |lesson| + Salesforce::LessonSyncJob.perform_later(lesson_id: lesson.id) + end + end end diff --git a/spec/factories/salesforce/class_teacher.rb b/spec/factories/salesforce/class_teacher.rb new file mode 100644 index 000000000..08be1f2a0 --- /dev/null +++ b/spec/factories/salesforce/class_teacher.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:salesforce_class_teacher, class: 'Salesforce::ClassTeacher') do + contactclassroomaffiliationuuid__c { SecureRandom.uuid } + end +end diff --git a/spec/factories/salesforce/lesson.rb b/spec/factories/salesforce/lesson.rb new file mode 100644 index 000000000..5b24d01b9 --- /dev/null +++ b/spec/factories/salesforce/lesson.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:salesforce_lesson, class: 'Salesforce::Lesson') do + lesson_uuid__c { SecureRandom.uuid } + end +end diff --git a/spec/factories/salesforce/school_class.rb b/spec/factories/salesforce/school_class.rb new file mode 100644 index 000000000..c02bdaa51 --- /dev/null +++ b/spec/factories/salesforce/school_class.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:salesforce_school_class, class: 'Salesforce::SchoolClass') do + classroomuuid__c { SecureRandom.uuid } + end +end diff --git a/spec/jobs/salesforce/class_teacher_sync_job_spec.rb b/spec/jobs/salesforce/class_teacher_sync_job_spec.rb new file mode 100644 index 000000000..11036b59e --- /dev/null +++ b/spec/jobs/salesforce/class_teacher_sync_job_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Salesforce::ClassTeacherSyncJob, :requires_salesforce_db do + subject(:perform_job) { described_class.perform_now(class_teacher_id: class_teacher.id) } + + let(:teacher) { create(:teacher, school:) } + let(:another_teacher) { create(:teacher, school:) } + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:class_teacher) { create(:class_teacher, school_class:, teacher_id: another_teacher.id) } + let!(:sf_school_class) do + create(:salesforce_school_class, classroomuuid__c: class_teacher.school_class_id, sfid: SecureRandom.alphanumeric(18)) + end + let!(:sf_contact) do + create(:salesforce_contact, pi_accounts_unique_id__c: class_teacher.teacher_id, sfid: SecureRandom.alphanumeric(18)) + end + + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } + end + + context 'when the job has run' do + before { perform_job } + + it 'syncs all FIELD_MAPPINGS to the correct class teacher values' do + sf_class_teacher = Salesforce::ClassTeacher.find_by(contactclassroomaffiliationuuid__c: class_teacher.id) + described_class::FIELD_MAPPINGS.each do |sf_field, class_teacher_field| + expected = Salesforce::ClassTeacher.type_for_attribute(sf_field).cast(class_teacher.send(class_teacher_field)) + expect(sf_class_teacher.send(sf_field)).to eq(expected) + end + end + end + + context 'when the Salesforce class teacher fails to save' do + let(:sf_class_teacher) { instance_double(Salesforce::ClassTeacher) } + + before do + allow(Salesforce::ClassTeacher).to receive(:find_or_initialize_by) + .with(contactclassroomaffiliationuuid__c: class_teacher.id).and_return(sf_class_teacher) + allow(sf_class_teacher).to receive(:attributes=) + allow(sf_class_teacher).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 parent Classroom__c is not yet synced to Salesforce' do + before { sf_school_class.update!(sfid: nil) } + + it 'retries the job to defer the affiliation write' do + expect { perform_job }.to have_enqueued_job(described_class).with(class_teacher_id: class_teacher.id) + end + + it 'does not write the affiliation to the mirror' do + perform_job + expect(Salesforce::ClassTeacher.find_by(contactclassroomaffiliationuuid__c: class_teacher.id)).to be_nil + end + end + + context 'when there is no Salesforce::SchoolClass row for the class teacher' do + before { sf_school_class.destroy } + + it 'retries the job' do + expect { perform_job }.to have_enqueued_job(described_class).with(class_teacher_id: class_teacher.id) + end + + it 'does not write the affiliation to the mirror' do + perform_job + expect(Salesforce::ClassTeacher.find_by(contactclassroomaffiliationuuid__c: class_teacher.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(class_teacher_id: class_teacher.id) + end + + it 'does not write the affiliation to the mirror' do + perform_job + expect(Salesforce::ClassTeacher.find_by(contactclassroomaffiliationuuid__c: class_teacher.id)).to be_nil + end + end + + context 'when there is no Salesforce::Contact row for the class teacher' do + before { sf_contact.destroy } + + it 'retries the job' do + expect { perform_job }.to have_enqueued_job(described_class).with(class_teacher_id: class_teacher.id) + end + + it 'does not write the affiliation to the mirror' do + perform_job + expect(Salesforce::ClassTeacher.find_by(contactclassroomaffiliationuuid__c: class_teacher.id)).to be_nil + end + end + + context 'when SALESFORCE_ENABLED is false' do + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'false') { example.run } + end + + it 'discards the job without syncing' do + perform_job + expect(Salesforce::ClassTeacher.find_by(contactclassroomaffiliationuuid__c: class_teacher.id)).to be_nil + end + end + + describe '#concurrency_key_id' do + it 'returns the class_teacher_id' do + job = described_class.new(class_teacher_id: class_teacher.id) + expect(job.send(:concurrency_key_id)).to eq(class_teacher.id) + end + end +end diff --git a/spec/jobs/salesforce/lesson_sync_job_spec.rb b/spec/jobs/salesforce/lesson_sync_job_spec.rb new file mode 100644 index 000000000..ba60b2ceb --- /dev/null +++ b/spec/jobs/salesforce/lesson_sync_job_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Salesforce::LessonSyncJob, :requires_salesforce_db do + include ActiveSupport::Testing::TimeHelpers + + subject(:perform_job) { described_class.perform_now(lesson_id: lesson.id) } + + let(:teacher) { create(:teacher, school:) } + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id) } + let!(:sf_school_class) do + next if lesson.school_class_id.blank? + + create(:salesforce_school_class, classroomuuid__c: lesson.school_class_id, sfid: SecureRandom.alphanumeric(18)) + end + + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } + end + + context 'when the job has run' do + before { perform_job } + + it 'syncs all FIELD_MAPPINGS to the correct lesson values' do + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) + described_class::FIELD_MAPPINGS.each do |sf_field, lesson_field| + expected = Salesforce::Lesson.type_for_attribute(sf_field).cast(lesson.send(lesson_field)) + expect(sf_lesson.send(sf_field)).to eq(expected) + end + end + + it 'syncs teacherprojecttitle__c from the lesson project name' do + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) + expect(sf_lesson.teacherprojecttitle__c).to eq(lesson.project.name) + end + + it 'syncs teacherprojecttype__c from the lesson project type' do + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) + expect(sf_lesson.teacherprojecttype__c).to eq(lesson.project.project_type) + end + end + + context 'when the lesson project has a remix' do + before do + student = create(:student, school:) + create(:project, school:, user_id: student.id, remixed_from_id: lesson.project.id) + perform_job + end + + it 'syncs numberofassignedprojects__c from the remix count' do + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) + expect(sf_lesson.numberofassignedprojects__c).to eq(1) + end + end + + context 'when the lesson has a submitted projects count' do + before do + lesson.update!(submitted_projects_count: 7) + perform_job + end + + it 'syncs numberofcompletedprojects__c from the lesson submitted_projects_count' do + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) + expect(sf_lesson.numberofcompletedprojects__c).to eq(7) + end + end + + context 'when the lesson has no project' do + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, project: nil) } + + before { perform_job } + + it 'leaves teacherprojecttitle__c nil' do + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) + expect(sf_lesson.teacherprojecttitle__c).to be_nil + end + + it 'leaves teacherprojecttype__c nil' do + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) + expect(sf_lesson.teacherprojecttype__c).to be_nil + end + end + + context 'when syncing lastsyncdate__c' do + it 'sets lastsyncdate__c to the time the job ran' do + freeze_time do + perform_job + sf_lesson = Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id) + expect(sf_lesson.lastsyncdate__c).to eq(Time.current) + end + end + end + + context 'when the lesson has no school class' do + let(:lesson) { create(:lesson, school:, user_id: teacher.id) } + + it 'does not create a Salesforce lesson record' do + perform_job + expect(Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id)).to be_nil + end + end + + context 'when the parent Classroom__c is not yet synced to Salesforce' do + before { sf_school_class.update!(sfid: nil) } + + it 'retries the job to defer the lesson write' do + expect { perform_job }.to have_enqueued_job(described_class).with(lesson_id: lesson.id) + end + + it 'does not write the lesson to the mirror' do + perform_job + expect(Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id)).to be_nil + end + end + + context 'when there is no Salesforce::SchoolClass row for the lesson' do + before { sf_school_class.destroy } + + it 'retries the job' do + expect { perform_job }.to have_enqueued_job(described_class).with(lesson_id: lesson.id) + end + + it 'does not write the lesson to the mirror' do + perform_job + expect(Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id)).to be_nil + end + end + + context 'when the Salesforce lesson fails to save' do + let(:sf_lesson) { instance_double(Salesforce::Lesson) } + + before do + allow(Salesforce::Lesson).to receive(:find_or_initialize_by) + .with(lesson_uuid__c: lesson.id).and_return(sf_lesson) + allow(sf_lesson).to receive(:attributes=) + allow(sf_lesson).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') { example.run } + end + + it 'discards the job without syncing' do + perform_job + expect(Salesforce::Lesson.find_by(lesson_uuid__c: lesson.id)).to be_nil + end + end + + describe '#concurrency_key_id' do + it 'returns the lesson_id' do + job = described_class.new(lesson_id: lesson.id) + expect(job.send(:concurrency_key_id)).to eq(lesson.id) + end + end +end diff --git a/spec/jobs/salesforce/school_class_sync_job_spec.rb b/spec/jobs/salesforce/school_class_sync_job_spec.rb new file mode 100644 index 000000000..cc00c0c5e --- /dev/null +++ b/spec/jobs/salesforce/school_class_sync_job_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Salesforce::SchoolClassSyncJob, :requires_salesforce_db do + include ActiveSupport::Testing::TimeHelpers + + subject(:perform_job) { described_class.perform_now(school_class_id: school_class.id) } + + let(:teacher) { create(:teacher, school:) } + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let!(:sf_school) do + create(:salesforce_school, editoruuid__c: school_class.school_id, sfid: SecureRandom.alphanumeric(18)) + end + + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } + end + + context 'when the job has run' do + before { perform_job } + + it 'syncs all FIELD_MAPPINGS to the correct school class values' do + sf_school_class = Salesforce::SchoolClass.find_by(classroomuuid__c: school_class.id) + described_class::FIELD_MAPPINGS.each do |sf_field, school_class_field| + expected = Salesforce::SchoolClass.type_for_attribute(sf_field).cast(school_class.send(school_class_field)) + expect(sf_school_class.send(sf_field)).to eq(expected) + end + end + end + + context 'when there is a student in the school class' do + before do + student = create(:student, school:) + create(:class_student, school_class:, student_id: student.id) + perform_job + end + + it 'syncs numberofmembers__c from class students' do + sf_school_class = Salesforce::SchoolClass.find_by(classroomuuid__c: school_class.id) + expect(sf_school_class.numberofmembers__c).to eq(1) + end + end + + context 'when syncing lastsyncdate__c' do + it 'sets lastsyncdate__c to the time the job ran' do + freeze_time do + perform_job + sf_school_class = Salesforce::SchoolClass.find_by(classroomuuid__c: school_class.id) + expect(sf_school_class.lastsyncdate__c).to eq(Time.current) + end + 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 school class write' do + expect { perform_job }.to have_enqueued_job(described_class).with(school_class_id: school_class.id) + end + + it 'does not write the school class to the mirror' do + perform_job + expect(Salesforce::SchoolClass.find_by(classroomuuid__c: school_class.id)).to be_nil + end + end + + context 'when there is no Salesforce::School row for the school class' do + before { sf_school.destroy } + + it 'retries the job' do + expect { perform_job }.to have_enqueued_job(described_class).with(school_class_id: school_class.id) + end + + it 'does not write the school class to the mirror' do + perform_job + expect(Salesforce::SchoolClass.find_by(classroomuuid__c: school_class.id)).to be_nil + end + end + + context 'when the Salesforce school class fails to save' do + let(:sf_school_class) { instance_double(Salesforce::SchoolClass) } + + before do + allow(Salesforce::SchoolClass).to receive(:find_or_initialize_by) + .with(classroomuuid__c: school_class.id).and_return(sf_school_class) + allow(sf_school_class).to receive(:attributes=) + allow(sf_school_class).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') { example.run } + end + + it 'discards the job without syncing' do + perform_job + expect(Salesforce::SchoolClass.find_by(classroomuuid__c: school_class.id)).to be_nil + end + end + + describe '#concurrency_key_id' do + it 'returns the school_class_id' do + job = described_class.new(school_class_id: school_class.id) + expect(job.send(:concurrency_key_id)).to eq(school_class.id) + end + end +end diff --git a/spec/models/class_student_salesforce_sync_spec.rb b/spec/models/class_student_salesforce_sync_spec.rb new file mode 100644 index 000000000..ba3fd2e5a --- /dev/null +++ b/spec/models/class_student_salesforce_sync_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassStudent do + describe 'salesforce sync' do + include ActiveJob::TestHelper + + before do + stub_user_info_api_for_users([teacher.id, student.id], users: [teacher, student]) + end + + let(:teacher) { create(:teacher, school:) } + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:student) { create(:student, school:) } + + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } + end + + it 'enqueues a classroom sync but not a lesson sync when a student joins a class' do + create(:lesson, school:, school_class:, user_id: teacher.id) + clear_enqueued_jobs + + expect do + create(:class_student, school_class:, student_id: student.id) + end.to have_enqueued_job(Salesforce::SchoolClassSyncJob) + .and(not_have_enqueued_job(Salesforce::LessonSyncJob)) + end + end +end diff --git a/spec/models/class_teacher_salesforce_sync_spec.rb b/spec/models/class_teacher_salesforce_sync_spec.rb new file mode 100644 index 000000000..ecfa5fa69 --- /dev/null +++ b/spec/models/class_teacher_salesforce_sync_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassTeacher do + describe 'salesforce sync' do + include ActiveJob::TestHelper + include ActiveSupport::Testing::TimeHelpers + + before do + stub_user_info_api_for_users([teacher.id, another_teacher.id], users: [teacher, another_teacher]) + end + + let(:teacher) { create(:teacher, school:) } + let(:another_teacher) { create(:teacher, school:) } + let(:school) { create(:school) } + # Eagerly create the school_class outside any `travel_to` block so that its real + # `created_at` triggers the "older than 1 second" guard in ClassTeacher#do_salesforce_sync. + let!(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } + end + + it 'enqueues classroom and class-teacher sync jobs when a teacher joins an existing class' do + clear_enqueued_jobs + + expect do + travel_to(2.seconds.from_now) do + create(:class_teacher, school_class:, teacher_id: another_teacher.id) + end + end.to have_enqueued_job(Salesforce::SchoolClassSyncJob) + .and have_enqueued_job(Salesforce::ClassTeacherSyncJob) + end + + it 'does not enqueue any sync jobs when class teachers are created with a brand-new class' do + clear_enqueued_jobs + + expect do + create(:school_class, teacher_ids: [teacher.id, another_teacher.id], school:) + end.not_to have_enqueued_job(Salesforce::ClassTeacherSyncJob) + end + + it 'enqueues classroom sync but not class-teacher sync when a class teacher is destroyed' do + class_teacher = travel_to(2.seconds.from_now) do + create(:class_teacher, school_class:, teacher_id: another_teacher.id) + end + clear_enqueued_jobs + + expect { class_teacher.destroy! } + .to have_enqueued_job(Salesforce::SchoolClassSyncJob) + .and(not_have_enqueued_job(Salesforce::ClassTeacherSyncJob)) + end + + context 'when SALESFORCE_ENABLED is false' do + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'false') { example.run } + end + + it 'does not enqueue Salesforce::ClassTeacherSyncJob on create' do + expect do + travel_to(2.seconds.from_now) do + create(:class_teacher, school_class:, teacher_id: another_teacher.id) + end + end.not_to have_enqueued_job(Salesforce::ClassTeacherSyncJob) + end + end + end +end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 0baef8301..dd9b50955 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -233,4 +233,35 @@ expect(lesson.reload.submitted_projects_count).to eq(2) end end + + describe 'salesforce sync' do + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } + end + + it 'enqueues Salesforce::LessonSyncJob on create when the lesson belongs to a class' do + expect do + create(:lesson, school:, school_class:, user_id: teacher.id) + end.to have_enqueued_job(Salesforce::LessonSyncJob) + end + + it 'does not enqueue Salesforce::LessonSyncJob for library lessons' do + expect { create(:lesson, school:, user_id: teacher.id) } + .not_to have_enqueued_job(Salesforce::LessonSyncJob) + end + + context 'when SALESFORCE_ENABLED is false' do + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'false') { example.run } + end + + it 'does not enqueue Salesforce::LessonSyncJob on create' do + expect do + create(:lesson, school:, school_class:, user_id: teacher.id) + end.not_to have_enqueued_job(Salesforce::LessonSyncJob) + end + end + end end diff --git a/spec/models/project_salesforce_sync_spec.rb b/spec/models/project_salesforce_sync_spec.rb new file mode 100644 index 000000000..fac9dae2c --- /dev/null +++ b/spec/models/project_salesforce_sync_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Project do + describe 'salesforce sync' do + include ActiveJob::TestHelper + + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id) } + + before do + stub_user_info_api_for_users([teacher.id, student.id], users: [teacher, student]) + create(:class_student, school_class:, student_id: student.id) + end + + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } + end + + it 'enqueues Salesforce::LessonSyncJob when a remix of a lesson project is created' do + lesson_project = lesson.project + clear_enqueued_jobs + + expect do + create(:project, school:, user_id: student.id, remixed_from_id: lesson_project.id, lesson:) + end.to have_enqueued_job(Salesforce::LessonSyncJob).with(lesson_id: lesson.id) + end + + it 'does not enqueue Salesforce::LessonSyncJob when a project has no parent' do + clear_enqueued_jobs + + expect { create(:project, user_id: student.id) } + .not_to have_enqueued_job(Salesforce::LessonSyncJob) + end + + it 'does not enqueue Salesforce::LessonSyncJob when the remix parent is not a lesson project' do + standalone_parent = create(:project, user_id: teacher.id) + clear_enqueued_jobs + + expect do + create(:project, user_id: student.id, remixed_from_id: standalone_parent.id) + end.not_to have_enqueued_job(Salesforce::LessonSyncJob) + end + + context 'when SALESFORCE_ENABLED is false' do + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'false') { example.run } + end + + it 'does not enqueue Salesforce::LessonSyncJob on remix create' do + lesson_project = lesson.project + clear_enqueued_jobs + + expect do + create(:project, school:, user_id: student.id, remixed_from_id: lesson_project.id, lesson:) + end.not_to have_enqueued_job(Salesforce::LessonSyncJob) + end + end + end +end diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index 5d6f43d08..9ce3ebc0a 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -390,4 +390,32 @@ expect(school_class.versions.length).to(eq(1)) end end + + describe 'salesforce sync' do + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run } + end + + it 'enqueues Salesforce::SchoolClassSyncJob on create' do + expect { create(:school_class, teacher_ids: [teacher.id], school:) } + .to have_enqueued_job(Salesforce::SchoolClassSyncJob) + end + + it 'enqueues Salesforce::SchoolClassSyncJob on update' do + school_class = create(:school_class, teacher_ids: [teacher.id], school:) + expect { school_class.update!(name: 'Updated class name') } + .to have_enqueued_job(Salesforce::SchoolClassSyncJob) + end + + context 'when SALESFORCE_ENABLED is false' do + around do |example| + ClimateControl.modify(SALESFORCE_ENABLED: 'false') { example.run } + end + + it 'does not enqueue Salesforce::SchoolClassSyncJob on create' do + expect { create(:school_class, teacher_ids: [teacher.id], school:) } + .not_to have_enqueued_job(Salesforce::SchoolClassSyncJob) + end + end + end end diff --git a/spec/support/negated_matchers.rb b/spec/support/negated_matchers.rb new file mode 100644 index 000000000..575ecfc7a --- /dev/null +++ b/spec/support/negated_matchers.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Negated forms of common matchers, for use inside compound expectations like +# `.to have_enqueued_job(A).and not_have_enqueued_job(B)`. +RSpec::Matchers.define_negated_matcher :not_have_enqueued_job, :have_enqueued_job