Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/jobs/update_school_email_domains_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

class UpdateSchoolEmailDomainsJob < ApplicationJob
class ConcurrencyExceededForSchool < StandardError; end

include GoodJob::ActiveJobExtensions::Concurrency

# Serialize runs per school (eg user edits domains while batch or another job is updating Profile).
good_job_control_concurrency_with(
perform_limit: 1,
key: -> { "#{self.class.name}/#{concurrency_key_id}" }
)

retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |_job, e|
Sentry.capture_exception(e)
raise e
end

# Don't retry...
rescue_from ConcurrencyExceededForSchool do |e|
Rails.logger.error "Only one job per school can be enqueued at a time: #{concurrency_key_id}"
Sentry.capture_exception(e)
raise e
end

queue_as :update_school_email_domains_job

def self.attempt_perform_later(school_id:, school_email_domains:, token:)
perform_later(school_id:, school_email_domains:, token:)
end

def perform(school_id:, school_email_domains:, token:)
ProfileApiClient.update_school_email_domains(token:, school_id:, school_email_domains:)
end

private

def concurrency_key_id
arguments.first&.with_indifferent_access&.dig(:school_id)
end
end
24 changes: 20 additions & 4 deletions lib/profile_api_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class ProfileApiClient
}.freeze

# rubocop:disable Naming/MethodName
School = Data.define(:id, :schoolCode, :updatedAt, :createdAt, :discardedAt)
School = Data.define(:id, :schoolCode, :studentEmailDomains, :updatedAt, :createdAt, :discardedAt)
SafeguardingFlag = Data.define(:id, :userId, :schoolId, :flag, :email, :createdAt, :updatedAt, :discardedAt)
Student = Data.define(:id, :schoolId, :name, :username, :createdAt, :updatedAt, :discardedAt, :email, :ssoProviders)
# rubocop:enable Naming/MethodName
Expand Down Expand Up @@ -51,13 +51,14 @@ def check_auth(token:)
false
end

def create_school(token:, id:, code:)
return { 'id' => id, 'schoolCode' => code } if ENV['BYPASS_OAUTH'].present?
def create_school(token:, id:, code:, school_email_domains: [])
return { 'id' => id, 'schoolCode' => code, 'studentEmailDomains' => school_email_domains } if ENV['BYPASS_OAUTH'].present?

response = connection(token).post('/api/v1/schools') do |request|
request.body = {
id:,
schoolCode: code
schoolCode: code,
studentEmailDomains: school_email_domains
}
end

Expand Down Expand Up @@ -214,6 +215,21 @@ def delete_safeguarding_flag(token:, flag:)
raise UnexpectedResponse, response unless response.status == 204
end

def update_school_email_domains(token:, school_id:, school_email_domains: [])
return { 'id' => school_id, 'studentEmailDomains' => school_email_domains } if ENV['BYPASS_OAUTH'].present?

response = connection(token).patch("/api/v1/schools/#{school_id}") do |request|
request.body = {
studentEmailDomains: school_email_domains
}
end

unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 201

School.new(**response.body)
end

private

def connection(token)
Expand Down
70 changes: 70 additions & 0 deletions spec/jobs/update_school_email_domains_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe UpdateSchoolEmailDomainsJob do
include ActiveJob::TestHelper

let(:token) { UserProfileMock::TOKEN }
let(:school) { create(:verified_school) }
let(:school_email_domains) { ['student.example.edu', 'mail.example.org'] }
let(:profile_api_base_url) { 'http://profile.com' }
let(:profile_api_key) { 'api-key' }
let(:update_school_email_domains_path) { "/api/v1/schools/#{school.id}" }
let(:update_school_email_domains_url) { "#{profile_api_base_url}#{update_school_email_domains_path}" }

around do |example|
ClimateControl.modify(
IDENTITY_URL: profile_api_base_url,
PROFILE_API_KEY: profile_api_key
) do
example.run
end
end

before do
stub_request(:patch, update_school_email_domains_url)
.to_return(
status: 201,
body: {
id: school.id,
schoolCode: '99-12-34',
updatedAt: '2024-07-09T10:31:13.196Z',
createdAt: '2024-07-09T10:31:13.196Z',
discardedAt: nil,
studentEmailDomains: school_email_domains
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end

after do
GoodJob::Job.delete_all
end

describe 'GoodJob concurrency' do
it 'uses a per-school concurrency key' do
job = described_class.new
job.arguments = [{ school_id: school.id, school_email_domains:, token: }]

expect(job.good_job_concurrency_key).to eq("#{described_class.name}/#{school.id}")
end

it 'uses a different concurrency key for a different school' do
other_school = create(:verified_school)
job_a = described_class.new
job_a.arguments = [{ school_id: school.id, school_email_domains:, token: }]
job_b = described_class.new
job_b.arguments = [{ school_id: other_school.id, school_email_domains:, token: }]

expect(job_a.good_job_concurrency_key).not_to eq(job_b.good_job_concurrency_key)
end
end

it 'PATCHes student email domains to the Profile API' do
described_class.perform_now(token:, school_id: school.id, school_email_domains:)
expect(WebMock).to have_requested(:patch, update_school_email_domains_url).with(
body: { studentEmailDomains: school_email_domains }.to_json
)
end
end
71 changes: 65 additions & 6 deletions spec/lib/profile_api_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
stub_request(:post, create_school_url)
.to_return(
status: 201,
body: '{"id":"","schoolCode":"","updatedAt":"","createdAt":"","discardedAt":""}',
body: '{"id":"","schoolCode":"","updatedAt":"","createdAt":"","discardedAt":"","studentEmailDomains":[]}',
headers: { 'content-type' => 'application/json' }
)
end
Expand All @@ -67,14 +67,15 @@
it_behaves_like 'a request that handles standard HTTP errors', :post, url: -> { create_school_url }
it_behaves_like 'a request that handles an unexpected response status', :post, url: -> { "#{api_url}/api/v1/schools" }, status: 200

it 'sends the school id and code in the request body as json' do
it 'sends the school id, code and studentEmailDomains in the request body as json' do
create_school_response
expected_body = { id: school.id, schoolCode: school.code }.to_json
expected_body = { id: school.id, schoolCode: school.code, studentEmailDomains: [] }.to_json
expect(WebMock).to have_requested(:post, create_school_url).with(body: expected_body)
end

it 'returns the created school if successful' do
data = { id: 'id', schoolCode: 'code', updatedAt: '2024-07-09T10:31:13.196Z', createdAt: '2024-07-09T10:31:13.196Z', discardedAt: nil }
data = { id: 'id', schoolCode: 'code', updatedAt: '2024-07-09T10:31:13.196Z', createdAt: '2024-07-09T10:31:13.196Z', discardedAt: nil,
studentEmailDomains: [] }
expected = ProfileApiClient::School.new(**data)
stub_request(:post, create_school_url)
.to_return(status: 201, body: data.to_json, headers: { 'Content-Type' => 'application/json' })
Expand All @@ -91,8 +92,8 @@
expect(WebMock).not_to have_requested(:post, create_school_url)
end

it 'returns the id and code of the school supplied' do
expected = { 'id' => school.id, 'schoolCode' => school.code }
it 'returns the id, code and email domains of the school supplied' do
expected = { 'id' => school.id, 'schoolCode' => school.code, 'studentEmailDomains' => [] }
expect(create_school_response).to eq(expected)
end
end
Expand Down Expand Up @@ -558,4 +559,62 @@ def school_student
described_class.school_student(token:, school_id: school.id, student_id:)
end
end

describe '.update_school_email_domains' do
subject(:update_school_email_domains_response) { update_school_email_domains }

let(:school) { build(:school, id: SecureRandom.uuid, code: SecureRandom.uuid) }
let(:update_school_email_domains_url) { "#{api_url}/api/v1/schools/#{school.id}" }
let(:school_email_domains) { ['student.example.edu', 'mail.example.org'] }

before do
stub_request(:patch, update_school_email_domains_url)
.to_return(
status: 201,
body: '{"id":"","schoolCode":"","updatedAt":"","createdAt":"","discardedAt":"","studentEmailDomains":[]}',
headers: { 'content-type' => 'application/json' }
)
end

it_behaves_like 'an authenticated JSON API request', :patch, url: -> { update_school_email_domains_url }
it_behaves_like 'a request that handles standard HTTP errors', :patch, url: -> { update_school_email_domains_url }
it_behaves_like 'a request that handles an unexpected response status', :patch, url: -> { "#{api_url}/api/v1/schools/#{school.id}" }, status: 200

it 'sends studentEmailDomains in the JSON body' do
update_school_email_domains_response
expected_body = { studentEmailDomains: school_email_domains }.to_json
expect(WebMock).to have_requested(:patch, update_school_email_domains_url).with(body: expected_body)
end

it 'returns a school if successful' do
data = { id: 'id', schoolCode: 'code', updatedAt: '2024-07-09T10:31:13.196Z', createdAt: '2024-07-09T10:31:13.196Z', discardedAt: nil,
studentEmailDomains: school_email_domains }
expected = ProfileApiClient::School.new(**data)
stub_request(:patch, update_school_email_domains_url)
.to_return(status: 201, body: data.to_json, headers: { 'Content-Type' => 'application/json' })
expect(update_school_email_domains_response).to eq(expected)
end

describe 'when BYPASS_OAUTH is true' do
before do
allow(ENV).to receive(:[]).with('BYPASS_OAUTH').and_return(true)
end

it 'does not make a request to Profile API' do
update_school_email_domains_response
expect(WebMock).not_to have_requested(:patch, update_school_email_domains_url)
end
Comment on lines +603 to +606
Copy link
Copy Markdown
Author

@PetarSimonovic PetarSimonovic May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using WebMock rather than expanding ProfileApiMock given the TODO comment at the top of that module


it 'returns the id and email domains of the school supplied' do
expected = { 'id' => school.id, 'studentEmailDomains' => school_email_domains }
expect(update_school_email_domains_response).to eq(expected)
end
end

private

def update_school_email_domains
described_class.update_school_email_domains(token:, school_id: school.id, school_email_domains:)
end
end
end
3 changes: 2 additions & 1 deletion spec/support/profile_api_mock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ def stub_profile_api_create_school_student(user_id: SecureRandom.uuid)
allow(ProfileApiClient).to receive(:create_school_student).and_return(created: [user_id])
end

def stub_profile_api_create_school(id: SecureRandom.uuid, code: '99-12-34')
def stub_profile_api_create_school(id: SecureRandom.uuid, code: '99-12-34', student_email_domains: [])
now = Time.current.to_fs(:iso8601) # rubocop:disable Naming/VariableNumber
allow(ProfileApiClient).to receive(:create_school).and_return(
ProfileApiClient::School.new(
id:,
schoolCode: code,
studentEmailDomains: student_email_domains,
updatedAt: now,
createdAt: now,
discardedAt: nil
Expand Down
Loading