Skip to content

Commit 4ea2fbf

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 80fb443 commit 4ea2fbf

7 files changed

Lines changed: 427 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.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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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
15+
response[:error] = response[:school_email_domain].errors.full_messages.join(', ')
16+
response
17+
rescue StandardError => e
18+
Sentry.capture_exception(e) # Send unexpected/Profile errors to Sentry
19+
response[:error] = e.message
20+
response
21+
end
22+
23+
private
24+
25+
def build_domain(school, domain)
26+
school.school_email_domains.build(domain:)
27+
end
28+
29+
def update_profile(school, token)
30+
school_email_domains = school.school_email_domains.pluck(:domain)
31+
ProfileApiClient.update_school_email_domains(token:, school_id: school.id, school_email_domains:)
32+
end
33+
end
34+
end
35+
end
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 match(/#{expected_error_message}/)
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 Profile sync fails' do
129+
let(:profile_error) do
130+
ProfileApiClient::UnexpectedResponse.new(
131+
instance_double(Faraday::Response, status: 500, headers: {}, body: '')
132+
)
133+
end
134+
135+
before do
136+
allow(Sentry).to receive(:capture_exception)
137+
138+
allow(ProfileApiClient).to receive(:update_school_email_domains)
139+
.and_raise(profile_error)
140+
end
141+
142+
it 'attempts to sync to Profile' do
143+
described_class.call(school:, domain:, token:)
144+
expect(ProfileApiClient).to have_received(:update_school_email_domains).once
145+
end
146+
147+
it 'does not persist the domain' do
148+
expect { described_class.call(school:, domain:, token:) }
149+
.not_to change(SchoolEmailDomain, :count)
150+
end
151+
152+
it 'sends the exception to Sentry' do
153+
described_class.call(school:, domain:, token:)
154+
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
155+
end
156+
157+
it 'returns a failed operation response' do
158+
expect(described_class.call(school:, domain:, token:)).to be_failure
159+
end
160+
161+
it 'returns the error message in the operation response' do
162+
response = described_class.call(school:, domain:, token:)
163+
expect(response[:error]).to eq('Unexpected response from Profile API (status code 500)')
164+
end
165+
end
166+
end

0 commit comments

Comments
 (0)