Skip to content

Commit 511affb

Browse files
Allow school owners and teachers to create school email domains
Add POST create to SchoolEmailDomainsController and route. Add SchoolEmailDomain::Create concept to persist domains and sync the full list with Profile.
1 parent ef13915 commit 511affb

7 files changed

Lines changed: 456 additions & 1 deletion

File tree

app/controllers/api/school_email_domains_controller.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,23 @@ def index
1010
render json: school_email_domains, status: :ok
1111
end
1212

13+
def create
14+
result = SchoolEmailDomain::Create.call(school: @school, domain: school_email_domain_params[:domain], token: current_user.token)
15+
if result.success?
16+
render json: { domain: result[:school_email_domain].domain }, status: :created
17+
else
18+
render json: { error: result[:error] }, status: :unprocessable_content
19+
end
20+
end
21+
1322
private
1423

1524
def school_email_domains
1625
@school.school_email_domains.order(:created_at).pluck(:domain)
1726
end
27+
28+
def school_email_domain_params
29+
params.expect(school_email_domain: [:domain])
30+
end
1831
end
1932
end

config/locales/en.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,12 @@ en:
2828
attributes:
2929
school_class:
3030
import_id: "Import id"
31+
errors:
32+
models:
33+
school_email_domain:
34+
attributes:
35+
domain:
36+
invalid: "is invalid"
37+
invalid_host: "must be a fully qualified domain name"
38+
invalid_public_suffix: "must be a registrable domain name"
39+
invalid_uri: "must be a valid domain format"

config/routes.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
post :batch, on: :collection, to: 'school_students#create_batch'
8383
delete :batch, on: :collection, to: 'school_students#destroy_batch'
8484
end
85-
resources :school_email_domains, only: %i[index], controller: 'school_email_domains'
85+
resources :school_email_domains, only: %i[index create], controller: 'school_email_domains'
8686
end
8787

8888
resources :lessons, only: %i[index create show update destroy] do
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
class SchoolEmailDomain
4+
class Create
5+
class << self
6+
def call(school:, domain:, token:)
7+
response = OperationResponse.new
8+
response[:school_email_domain] = build_domain(school, domain)
9+
SchoolEmailDomain.transaction do
10+
response[:school_email_domain].save!
11+
update_profile(school, token)
12+
end
13+
response
14+
rescue ActiveRecord::RecordInvalid => e
15+
record = response[:school_email_domain] || e.record
16+
response[:error] = record.errors.full_messages.join(', ')
17+
response
18+
rescue ActiveRecord::RecordNotUnique
19+
record = response[:school_email_domain]
20+
record.errors.add(:domain, :taken)
21+
response[:error] = record.errors.full_messages.join(', ')
22+
response
23+
rescue StandardError => e
24+
Sentry.capture_exception(e) # Send unexpected/Profile errors to Sentry
25+
response[:error] = e.message
26+
response
27+
end
28+
29+
private
30+
31+
def build_domain(school, domain)
32+
school.school_email_domains.build(domain:)
33+
end
34+
35+
def update_profile(school, token)
36+
school_email_domains = school.school_email_domains.order(:created_at).pluck(:domain)
37+
ProfileApiClient.update_school_email_domains(token:, school_id: school.id, school_email_domains:)
38+
end
39+
end
40+
end
41+
end
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe SchoolEmailDomain::Create, type: :unit do
6+
let(:school) { create(:school) }
7+
let(:domain) { 'school.edu' }
8+
let(:token) { UserProfileMock::TOKEN }
9+
10+
before { stub_profile_api_update_school_email_domains }
11+
12+
context 'with valid values' do
13+
it 'returns a successful operation response' do
14+
response = described_class.call(school:, domain:, token:)
15+
expect(response.success?).to be(true)
16+
end
17+
18+
it 'creates a school email domain' do
19+
expect { described_class.call(school:, domain:, token:) }.to change(SchoolEmailDomain, :count).by(1)
20+
end
21+
22+
it 'returns the domain in the operation response' do
23+
response = described_class.call(school:, domain:, token:)
24+
expect(response[:school_email_domain]).to be_a(SchoolEmailDomain)
25+
end
26+
27+
it 'assigns the domain' do
28+
response = described_class.call(school:, domain:, token:)
29+
expect(response[:school_email_domain].domain).to eq(domain)
30+
end
31+
32+
it 'assigns the school' do
33+
response = described_class.call(school:, domain:, token:)
34+
expect(response[:school_email_domain].school_id).to eq(school.id)
35+
end
36+
37+
it 'syncs the domains to Profile' do
38+
described_class.call(school:, domain:, token:)
39+
expect(ProfileApiClient).to have_received(:update_school_email_domains).with(
40+
token:,
41+
school_id: school.id,
42+
school_email_domains: [domain]
43+
)
44+
end
45+
46+
context 'when multiple domains already exist' do
47+
before do
48+
create(:school_email_domain, school:, domain: 'first.edu')
49+
create(:school_email_domain, school:, domain: 'second.edu')
50+
create(:school_email_domain, school:, domain: 'third.edu')
51+
end
52+
53+
it 'syncs all domains to Profile' do
54+
described_class.call(school:, domain:, token:)
55+
expect(ProfileApiClient).to have_received(:update_school_email_domains).with(
56+
token:,
57+
school_id: school.id,
58+
school_email_domains: ['first.edu', 'second.edu', 'third.edu', domain]
59+
)
60+
end
61+
end
62+
end
63+
64+
shared_examples 'an invalid record' do
65+
before { allow(Sentry).to receive(:capture_exception) }
66+
67+
it 'does not create a school email domain' do
68+
expect { described_class.call(school:, domain:, token:) }.not_to change(SchoolEmailDomain, :count)
69+
end
70+
71+
it 'returns a failed operation response' do
72+
response = described_class.call(school:, domain:, token:)
73+
expect(response.failure?).to be(true)
74+
end
75+
76+
it 'does not send the exception to Sentry' do
77+
described_class.call(school:, domain:, token:)
78+
expect(Sentry).not_to have_received(:capture_exception).with(kind_of(StandardError))
79+
end
80+
81+
it 'returns the error message in the operation response' do
82+
response = described_class.call(school:, domain:, token:)
83+
expect(response[:error]).to include('')
84+
end
85+
86+
it 'does not attempt to update Profile' do
87+
described_class.call(school:, domain:, token:)
88+
expect(ProfileApiClient).not_to have_received(:update_school_email_domains)
89+
end
90+
end
91+
92+
context 'when domain is blank' do
93+
let(:domain) { '' }
94+
let(:expected_error_message) { "Domain can't be blank" }
95+
96+
it_behaves_like 'an invalid record'
97+
end
98+
99+
context 'when domain is not an FQDN' do
100+
let(:domain) { 'edu' }
101+
let(:expected_error_message) { 'Domain must be a fully qualified domain name' }
102+
103+
it_behaves_like 'an invalid record'
104+
end
105+
106+
context 'when domain has an invalid URI' do
107+
let(:domain) { 'exa mple.com' }
108+
let(:expected_error_message) { 'Domain must be a valid domain format' }
109+
110+
it_behaves_like 'an invalid record'
111+
end
112+
113+
context 'when domain has an invalid public suffix' do
114+
let(:domain) { 'co.uk' }
115+
let(:expected_error_message) { 'Domain must be a registrable domain name' }
116+
117+
it_behaves_like 'an invalid record'
118+
end
119+
120+
context 'when domain is a duplicate' do
121+
before { create(:school_email_domain, school:, domain:) }
122+
123+
let(:expected_error_message) { 'Domain has already been taken' }
124+
125+
it_behaves_like 'an invalid record'
126+
end
127+
128+
context 'when a concurrent request creates the same domain' do
129+
let(:expected_error_message) { 'Domain has already been taken' }
130+
let(:school_email_domain) { SchoolEmailDomain.new(school:, domain:) }
131+
132+
before do
133+
allow(Sentry).to receive(:capture_exception)
134+
allow(school.school_email_domains).to receive(:build).with(domain:).and_return(school_email_domain)
135+
allow(school_email_domain).to receive(:save!).and_raise(ActiveRecord::RecordNotUnique)
136+
end
137+
138+
it_behaves_like 'an invalid record'
139+
end
140+
141+
context 'when Profile sync fails' do
142+
let(:profile_error) do
143+
ProfileApiClient::UnexpectedResponse.new(
144+
instance_double(Faraday::Response, status: 500, headers: {}, body: '')
145+
)
146+
end
147+
148+
before do
149+
allow(Sentry).to receive(:capture_exception)
150+
151+
allow(ProfileApiClient).to receive(:update_school_email_domains)
152+
.and_raise(profile_error)
153+
end
154+
155+
it 'attempts to sync to Profile' do
156+
described_class.call(school:, domain:, token:)
157+
expect(ProfileApiClient).to have_received(:update_school_email_domains).once
158+
end
159+
160+
it 'does not persist the domain' do
161+
expect { described_class.call(school:, domain:, token:) }
162+
.not_to change(SchoolEmailDomain, :count)
163+
end
164+
165+
it 'sends the exception to Sentry' do
166+
described_class.call(school:, domain:, token:)
167+
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
168+
end
169+
170+
it 'returns a failed operation response' do
171+
expect(described_class.call(school:, domain:, token:)).to be_failure
172+
end
173+
174+
it 'returns the error message in the operation response' do
175+
response = described_class.call(school:, domain:, token:)
176+
expect(response[:error]).to eq('Unexpected response from Profile API (status code 500)')
177+
end
178+
end
179+
end

0 commit comments

Comments
 (0)