Skip to content

Commit ea1c1d6

Browse files
committed
Add Api::TeacherInvitationsController#accept action
This is intended to handle when the teacher clicks the button to accept the invitation. We need to do all the same checks we did for the `#show` action, i.e. that the invitation token is valid for the currently logged-in user. When the action is successful, the currently logged-in user is assigned the "teacher" role in the school associated with the invitation. The `Invitation#accepted_at` timestamp is also updated with the current time. There are a number of validation errors that can cause the action to fail. These result in a response with a 422 Unprocessable entity HTTP status code with the validation error Hash from `Invitation#errors` included in the "error" key of the JSON in the response body.
1 parent 44906df commit ea1c1d6

File tree

3 files changed

+258
-1
lines changed

3 files changed

+258
-1
lines changed

app/controllers/api/teacher_invitations_controller.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ def show
1212
render :show, formats: [:json], status: :ok
1313
end
1414

15+
def accept
16+
role = Role.teacher.build(user_id: current_user.id, school: @invitation.school)
17+
if role.valid?
18+
role.save
19+
@invitation.update!(accepted_at: Time.current) if @invitation.accepted_at.blank?
20+
head :ok
21+
else
22+
render json: { error: role.errors }, status: :unprocessable_entity
23+
end
24+
end
25+
1526
private
1627

1728
def load_invitation

config/routes.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@
5454
post :copy, on: :member, to: 'lessons#create_copy'
5555
end
5656

57-
resources :teacher_invitations, param: :token, only: :show
57+
resources :teacher_invitations, param: :token, only: :show do
58+
put :accept, on: :member
59+
end
5860
end
5961

6062
resource :github_webhooks, only: :create, defaults: { formats: :json }
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Accepting an invitations', type: :request do
6+
include ActiveSupport::Testing::TimeHelpers
7+
8+
let(:user) { create(:user) }
9+
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
10+
11+
context 'when user is not logged in' do
12+
it 'responds 401 Unauthorized' do
13+
put('/api/teacher_invitations/fake-token/accept')
14+
expect(response).to have_http_status(:unauthorized)
15+
end
16+
end
17+
18+
context 'when user is logged in' do
19+
before do
20+
authenticated_in_hydra_as(user)
21+
end
22+
23+
context 'when invitation does not exist' do
24+
let(:invitation) { build(:invitation) }
25+
let!(:token) { invitation.generate_token_for(:teacher_invitation) }
26+
27+
it 'responds 404 Not Found' do
28+
put("/api/teacher_invitations/#{token}/accept", headers:)
29+
expect(response).to have_http_status(:not_found)
30+
end
31+
end
32+
33+
context 'when invitation exists' do
34+
let(:school) { create(:verified_school) }
35+
let(:invitation_email) { user.email }
36+
let(:invitation) { create(:invitation, email_address: invitation_email, school:) }
37+
let!(:token) { invitation.generate_token_for(:teacher_invitation) }
38+
39+
context 'when invitation token is not valid because invitation email has changed' do
40+
before do
41+
invitation.update!(email_address: "not-#{invitation.email_address}")
42+
end
43+
44+
it 'responds 403 Forbidden' do
45+
put("/api/teacher_invitations/#{token}/accept", headers:)
46+
expect(response).to have_http_status(:forbidden)
47+
end
48+
end
49+
50+
context 'when invitation token is not valid because token has expired' do
51+
it 'responds 403 Forbidden' do
52+
travel 31.days do
53+
put("/api/teacher_invitations/#{token}/accept", headers:)
54+
end
55+
expect(response).to have_http_status(:forbidden)
56+
end
57+
end
58+
59+
context 'when invitation email does not match user email' do
60+
let(:invitation_email) { "not-#{user.email}" }
61+
62+
it 'responds 403 Forbidden' do
63+
put("/api/teacher_invitations/#{token}/accept", headers:)
64+
expect(response).to have_http_status(:forbidden)
65+
end
66+
end
67+
68+
context 'when user already has student role for the same school' do
69+
before do
70+
Role.student.create!(user_id: user.id, school:)
71+
end
72+
73+
it 'responds 422 Unprocessable entity' do
74+
put("/api/teacher_invitations/#{token}/accept", headers:)
75+
76+
expect(response).to have_http_status(:unprocessable_entity)
77+
end
78+
79+
it 'does not give the user the teacher role for the school to which they have been invited' do
80+
put("/api/teacher_invitations/#{token}/accept", headers:)
81+
82+
expect(user).not_to be_school_teacher(school)
83+
end
84+
85+
it 'includes validation errors in response' do
86+
put("/api/teacher_invitations/#{token}/accept", headers:)
87+
88+
json = JSON.parse(response.body)
89+
expect(json['error']).to eq({ 'base' => ['Cannot create teacher role as this user already has the student role for this school'] })
90+
end
91+
92+
it 'does not set the accepted_at timestamp on the invitation' do
93+
freeze_time(with_usec: false) do
94+
put("/api/teacher_invitations/#{token}/accept", headers:)
95+
96+
expect(invitation.reload.accepted_at).to be_blank
97+
end
98+
end
99+
end
100+
101+
context 'when user already has teacher role for the same school' do
102+
before do
103+
Role.teacher.create!(user_id: user.id, school:)
104+
end
105+
106+
it 'responds 422 Unprocessable entity' do
107+
put("/api/teacher_invitations/#{token}/accept", headers:)
108+
109+
expect(response).to have_http_status(:unprocessable_entity)
110+
end
111+
112+
it 'leaves the user with the teacher role for that school' do
113+
put("/api/teacher_invitations/#{token}/accept", headers:)
114+
115+
expect(user).to be_school_teacher(school)
116+
end
117+
118+
it 'includes validation errors in response' do
119+
put("/api/teacher_invitations/#{token}/accept", headers:)
120+
121+
json = JSON.parse(response.body)
122+
expect(json['error']).to eq({ 'role' => ['has already been taken'] })
123+
end
124+
125+
it 'does not set the accepted_at timestamp on the invitation' do
126+
freeze_time(with_usec: false) do
127+
put("/api/teacher_invitations/#{token}/accept", headers:)
128+
129+
expect(invitation.reload.accepted_at).to be_blank
130+
end
131+
end
132+
end
133+
134+
context 'when user already has a role for another school' do
135+
let(:another_shool) { create(:school) }
136+
137+
before do
138+
Role.teacher.create!(user_id: user.id, school: another_shool)
139+
end
140+
141+
it 'responds 422 Unprocessable entity' do
142+
put("/api/teacher_invitations/#{token}/accept", headers:)
143+
144+
expect(response).to have_http_status(:unprocessable_entity)
145+
end
146+
147+
it 'does not give the user the teacher role for the school to which they have been invited' do
148+
put("/api/teacher_invitations/#{token}/accept", headers:)
149+
150+
expect(user).not_to be_school_teacher(school)
151+
end
152+
153+
it 'includes validation errors in response' do
154+
put("/api/teacher_invitations/#{token}/accept", headers:)
155+
156+
json = JSON.parse(response.body)
157+
expect(json['error']).to eq({ 'base' => ['Cannot create role as this user already has a role in a different school'] })
158+
end
159+
160+
it 'does not set the accepted_at timestamp on the invitation' do
161+
freeze_time(with_usec: false) do
162+
put("/api/teacher_invitations/#{token}/accept", headers:)
163+
164+
expect(invitation.reload.accepted_at).to be_blank
165+
end
166+
end
167+
end
168+
169+
context 'when invitation token is valid' do
170+
it 'responds 200 OK' do
171+
put("/api/teacher_invitations/#{token}/accept", headers:)
172+
173+
expect(response).to have_http_status(:ok)
174+
end
175+
176+
it 'gives the user the teacher role for the school to which they have been invited' do
177+
put("/api/teacher_invitations/#{token}/accept", headers:)
178+
179+
expect(user).to be_school_teacher(school)
180+
end
181+
182+
it 'sets the accepted_at timestamp on the invitation' do
183+
freeze_time(with_usec: false) do
184+
put("/api/teacher_invitations/#{token}/accept", headers:)
185+
186+
expect(invitation.reload.accepted_at).to eq(Time.current)
187+
end
188+
end
189+
end
190+
191+
context 'when invitation has already been accepted' do
192+
let(:original_accepted_at) { 1.week.ago.noon }
193+
194+
before do
195+
invitation.update!(accepted_at: original_accepted_at)
196+
end
197+
198+
it 'responds 200 OK' do
199+
put("/api/teacher_invitations/#{token}/accept", headers:)
200+
201+
expect(response).to have_http_status(:ok)
202+
end
203+
204+
it 'does not update the accepted_at timestamp on the invitation' do
205+
put("/api/teacher_invitations/#{token}/accept", headers:)
206+
207+
expect(invitation.reload.accepted_at).to eq(original_accepted_at)
208+
end
209+
end
210+
211+
context 'when user already has owner role for the same school' do
212+
before do
213+
Role.owner.create!(user_id: user.id, school:)
214+
end
215+
216+
it 'responds 200 OK' do
217+
put("/api/teacher_invitations/#{token}/accept", headers:)
218+
219+
expect(response).to have_http_status(:ok)
220+
end
221+
222+
it 'gives the user the teacher role for the school to which they have been invited' do
223+
put("/api/teacher_invitations/#{token}/accept", headers:)
224+
225+
expect(user).to be_school_teacher(school)
226+
end
227+
228+
it 'leaves the user with the owner role for that school' do
229+
put("/api/teacher_invitations/#{token}/accept", headers:)
230+
231+
expect(user).to be_school_owner(school)
232+
end
233+
234+
it 'sets the accepted_at timestamp on the invitation' do
235+
freeze_time(with_usec: false) do
236+
put("/api/teacher_invitations/#{token}/accept", headers:)
237+
238+
expect(invitation.reload.accepted_at).to eq(Time.current)
239+
end
240+
end
241+
end
242+
end
243+
end
244+
end

0 commit comments

Comments
 (0)