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
32 changes: 32 additions & 0 deletions app/controllers/api/school_email_domains_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module Api
class SchoolEmailDomainsController < ApiController
before_action :authorize_user
load_and_authorize_resource :school
authorize_resource :school_email_domain, class: false

def index
render json: school_email_domains, status: :ok
end

def create
result = SchoolEmailDomain::Create.call(school: @school, domain: school_email_domain_params[:domain], token: current_user.token)
if result.success?
render json: { domain: result[:school_email_domain].domain }, status: :created
else
render json: { error: result[:error] }, status: :unprocessable_content
end
end

private

def school_email_domains
@school.school_email_domains.order(:created_at).pluck(:domain)
end

def school_email_domain_params
params.expect(school_email_domain: [:domain])
end
end
end
2 changes: 2 additions & 0 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def define_school_owner_abilities(school:)
can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public])
can(%i[read destroy], Feedback, school_project: { school_id: school.id })
can(%i[exchange_code], :google_auth)
can(%i[read create], :school_email_domain)
end

def define_school_teacher_abilities(user:, school:)
Expand Down Expand Up @@ -102,6 +103,7 @@ def define_school_teacher_abilities(user:, school:)
can(%i[show_status unsubmit return complete], SchoolProject, project: { remixed_from_id: teacher_project_ids })
can(%i[read create destroy], Feedback, school_project: { project: { remixed_from_id: teacher_project_ids } })
can(%i[exchange_code], :google_auth)
can(%i[read create], :school_email_domain)
end

def define_school_student_abilities(user:, school:)
Expand Down
9 changes: 9 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,12 @@ en:
attributes:
school_class:
import_id: "Import id"
errors:
models:
school_email_domain:
attributes:
domain:
invalid: "is invalid"
invalid_host: "must be a fully qualified domain name"
invalid_public_suffix: "must be a registrable domain name"
invalid_uri: "must be a valid domain format"
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
post :batch, on: :collection, to: 'school_students#create_batch'
delete :batch, on: :collection, to: 'school_students#destroy_batch'
end
resources :school_email_domains, only: %i[index create], controller: 'school_email_domains'
end

resources :lessons, only: %i[index create show update destroy] do
Expand Down
41 changes: 41 additions & 0 deletions lib/concepts/school_email_domain/operations/create.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

class SchoolEmailDomain
class Create
class << self
def call(school:, domain:, token:)
response = OperationResponse.new
response[:school_email_domain] = build_domain(school, domain)
SchoolEmailDomain.transaction do
response[:school_email_domain].save!
update_profile(school, token)
end
Comment thread
PetarSimonovic marked this conversation as resolved.
response
rescue ActiveRecord::RecordInvalid => e
record = response[:school_email_domain] || e.record
response[:error] = record.errors.full_messages.join(', ')
response
rescue ActiveRecord::RecordNotUnique
record = response[:school_email_domain]
record.errors.add(:domain, :taken)
response[:error] = record.errors.full_messages.join(', ')
response
rescue StandardError => e
Sentry.capture_exception(e) # Send unexpected/Profile errors to Sentry
response[:error] = e.message

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think if OperationResponse.new fails, response will be nil anyway.

response
end

private

def build_domain(school, domain)
school.school_email_domains.build(domain:)
end

def update_profile(school, token)
school_email_domains = school.school_email_domains.order(:created_at).pluck(:domain)
ProfileApiClient.update_school_email_domains(token:, school_id: school.id, school_email_domains:)
end
Comment thread
PetarSimonovic marked this conversation as resolved.
end
end
end
179 changes: 179 additions & 0 deletions spec/concepts/school_email_domain/create_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe SchoolEmailDomain::Create, type: :unit do
let(:school) { create(:school) }
let(:domain) { 'school.edu' }
let(:token) { UserProfileMock::TOKEN }

before { stub_profile_api_update_school_email_domains }

context 'with valid values' do
it 'returns a successful operation response' do
response = described_class.call(school:, domain:, token:)
expect(response.success?).to be(true)
end

it 'creates a school email domain' do
expect { described_class.call(school:, domain:, token:) }.to change(SchoolEmailDomain, :count).by(1)
end

it 'returns the domain in the operation response' do
response = described_class.call(school:, domain:, token:)
expect(response[:school_email_domain]).to be_a(SchoolEmailDomain)
end

it 'assigns the domain' do
response = described_class.call(school:, domain:, token:)
expect(response[:school_email_domain].domain).to eq(domain)
end

it 'assigns the school' do
response = described_class.call(school:, domain:, token:)
expect(response[:school_email_domain].school_id).to eq(school.id)
end

it 'syncs the domains to Profile' do
described_class.call(school:, domain:, token:)
expect(ProfileApiClient).to have_received(:update_school_email_domains).with(
token:,
school_id: school.id,
school_email_domains: [domain]
)
end

context 'when multiple domains already exist' do
before do
create(:school_email_domain, school:, domain: 'first.edu')
create(:school_email_domain, school:, domain: 'second.edu')
create(:school_email_domain, school:, domain: 'third.edu')
end

it 'syncs all domains to Profile' do
described_class.call(school:, domain:, token:)
expect(ProfileApiClient).to have_received(:update_school_email_domains).with(
token:,
school_id: school.id,
school_email_domains: ['first.edu', 'second.edu', 'third.edu', domain]
)
end
end
end

shared_examples 'an invalid record' do
before { allow(Sentry).to receive(:capture_exception) }

it 'does not create a school email domain' do
expect { described_class.call(school:, domain:, token:) }.not_to change(SchoolEmailDomain, :count)
end

it 'returns a failed operation response' do
response = described_class.call(school:, domain:, token:)
expect(response.failure?).to be(true)
end

it 'does not send the exception to Sentry' do
described_class.call(school:, domain:, token:)
expect(Sentry).not_to have_received(:capture_exception).with(kind_of(StandardError))
end

it 'returns the error message in the operation response' do
response = described_class.call(school:, domain:, token:)
expect(response[:error]).to include('')
end

it 'does not attempt to update Profile' do
described_class.call(school:, domain:, token:)
expect(ProfileApiClient).not_to have_received(:update_school_email_domains)
end
end

context 'when domain is blank' do
let(:domain) { '' }
let(:expected_error_message) { "Domain can't be blank" }

it_behaves_like 'an invalid record'
end

context 'when domain is not an FQDN' do
let(:domain) { 'edu' }
let(:expected_error_message) { 'Domain must be a fully qualified domain name' }

it_behaves_like 'an invalid record'
end

context 'when domain has an invalid URI' do
let(:domain) { 'exa mple.com' }
let(:expected_error_message) { 'Domain must be a valid domain format' }

it_behaves_like 'an invalid record'
end

context 'when domain has an invalid public suffix' do
let(:domain) { 'co.uk' }
let(:expected_error_message) { 'Domain must be a registrable domain name' }

it_behaves_like 'an invalid record'
end

context 'when domain is a duplicate' do
before { create(:school_email_domain, school:, domain:) }

let(:expected_error_message) { 'Domain has already been taken' }

it_behaves_like 'an invalid record'
end

context 'when a concurrent request creates the same domain' do
let(:expected_error_message) { 'Domain has already been taken' }
let(:school_email_domain) { SchoolEmailDomain.new(school:, domain:) }

before do
allow(Sentry).to receive(:capture_exception)
allow(school.school_email_domains).to receive(:build).with(domain:).and_return(school_email_domain)
allow(school_email_domain).to receive(:save!).and_raise(ActiveRecord::RecordNotUnique)
end

it_behaves_like 'an invalid record'
end

context 'when Profile sync fails' do
let(:profile_error) do
ProfileApiClient::UnexpectedResponse.new(
instance_double(Faraday::Response, status: 500, headers: {}, body: '')
)
end

before do
allow(Sentry).to receive(:capture_exception)

allow(ProfileApiClient).to receive(:update_school_email_domains)
.and_raise(profile_error)
end

it 'attempts to sync to Profile' do
described_class.call(school:, domain:, token:)
expect(ProfileApiClient).to have_received(:update_school_email_domains).once
end

it 'does not persist the domain' do
expect { described_class.call(school:, domain:, token:) }
.not_to change(SchoolEmailDomain, :count)
end

it 'sends the exception to Sentry' do
described_class.call(school:, domain:, token:)
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
end

it 'returns a failed operation response' do
expect(described_class.call(school:, domain:, token:)).to be_failure
end

it 'returns the error message in the operation response' do
response = described_class.call(school:, domain:, token:)
expect(response[:error]).to eq('Unexpected response from Profile API (status code 500)')
end
end
end
8 changes: 8 additions & 0 deletions spec/factories/school_email_domain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

FactoryBot.define do
factory :school_email_domain do
school
sequence(:domain) { |n| "domain#{n}.example.edu" }
end
end
Loading
Loading