Skip to content

Commit 2d8ec2e

Browse files
committed
Implement ProfileApiClient.create_school_student
Note that if the student is successfully created in Profile API then we receive their ID in the response. We use this to create the relevant student role and also make it available in the OperationResponse returned by `SchoolStudent::Create.call` so that clients of Editor API can use it if necessary. I'm stripping any extraneous leading and trailing spaces from name, username and password in ProfileApiClient.create_school_student to avoid the sorts of problems that can occur when usernames and passwords include these unnecessary spaces. I've tested the Profile API and it's not (currently, at least) doing any kind of normalizing of what we pass to it. Note that I'm intentionally not doing anything different if `BYPASS_OAUTH` is set. Given how tightly coupled Editor API and Profile API are I'm less convinced it makes sense to attempt to run one without the other in development. API docs: https://my.raspberrypi.org/api/v1/documentation/#/Students/post_schools__schoolId__students
1 parent 13d098a commit 2d8ec2e

File tree

7 files changed

+190
-21
lines changed

7 files changed

+190
-21
lines changed

lib/concepts/school_student/create.rb

+10-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ class Create
55
class << self
66
def call(school:, school_student_params:, token:)
77
response = OperationResponse.new
8-
create_student(school, school_student_params, token)
8+
response[:student_id] = create_student(school, school_student_params, token)
9+
response
10+
rescue ProfileApiClient::CreateStudent422Error => e
11+
Sentry.capture_exception(e)
12+
response[:error] = "Error creating school student: #{e.error}"
913
response
1014
rescue StandardError => e
1115
Sentry.capture_exception(e)
@@ -16,14 +20,17 @@ def call(school:, school_student_params:, token:)
1620
private
1721

1822
def create_student(school, school_student_params, token)
19-
organisation_id = school.id
23+
school_id = school.id
2024
username = school_student_params.fetch(:username)
2125
password = school_student_params.fetch(:password)
2226
name = school_student_params.fetch(:name)
2327

2428
validate(school:, username:, password:, name:)
2529

26-
ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id:)
30+
response = ProfileApiClient.create_school_student(token:, username:, password:, name:, school_id:)
31+
user_id = response[:created].first
32+
Role.student.create!(school:, user_id:)
33+
user_id
2734
end
2835

2936
def validate(school:, username:, password:, name:)

lib/concepts/school_student/create_batch.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def create_batch(school, uploaded_file, token)
2323
validate(school:, sheet:)
2424

2525
non_header_rows_with_content(sheet:).each do |name, username, password|
26-
ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id: school.id)
26+
ProfileApiClient.create_school_student(token:, username:, password:, name:, school_id: school.id)
2727
end
2828
end
2929

lib/profile_api_client.rb

+35-9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ class ProfileApiClient
66
owner: 'school:owner'
77
}.freeze
88

9+
class Error < StandardError; end
10+
11+
class CreateStudent422Error < Error
12+
DEFAULT_ERROR = 'unknown error'
13+
ERRORS = {
14+
'ERR_USER_EXISTS' => 'username has already been taken',
15+
'ERR_INVALID' => 'unknown validation error',
16+
'ERR_INVALID_PASSWORD' => 'password is invalid',
17+
'ERR_UNKNOWN' => DEFAULT_ERROR
18+
}.freeze
19+
20+
attr_reader :username, :error
21+
22+
def initialize(error)
23+
@username = error['username']
24+
@error = ERRORS.fetch(error['error'], DEFAULT_ERROR)
25+
26+
super "Student not created in Profile API (status code 422, username '#{@username}', error '#{@error}')"
27+
end
28+
end
29+
930
class << self
1031
# TODO: Replace with HTTP requests once the profile API has been built.
1132

@@ -146,19 +167,24 @@ def list_school_students(token:, organisation_id:)
146167
# The API should respond:
147168
# - 404 Not Found if the user doesn't exist
148169
# - 422 Unprocessable if the constraints are not met
149-
def create_school_student(token:, username:, password:, name:, organisation_id:)
170+
# rubocop:disable Metrics/AbcSize
171+
def create_school_student(token:, username:, password:, name:, school_id:)
150172
return nil if token.blank?
151173

152-
_ = username
153-
_ = password
154-
_ = name
155-
_ = organisation_id
174+
response = connection(token).post("/api/v1/schools/#{school_id}/students") do |request|
175+
request.body = [{
176+
name: name.strip,
177+
username: username.strip,
178+
password: password.strip
179+
}]
180+
end
156181

157-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
158-
# code so that SchoolStudent::Create propagates the error in the response.
159-
response = {}
160-
response.deep_symbolize_keys
182+
raise CreateStudent422Error, response.body['errors'].first if response.status == 422
183+
raise "Student not created in Profile API (status code #{response.status})" unless response.status == 201
184+
185+
response.body.deep_symbolize_keys
161186
end
187+
# rubocop:enable Metrics/AbcSize
162188

163189
# The API should enforce these constraints:
164190
# - The token has the school-owner or school-teacher role for the given organisation ID

spec/concepts/school_student/create_batch_spec.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@
2121

2222
# TODO: Replace with WebMock assertion once the profile API has been built.
2323
expect(ProfileApiClient).to have_received(:create_school_student)
24-
.with(token:, username: 'jane123', password: 'secret123', name: 'Jane Doe', organisation_id: school.id)
24+
.with(token:, username: 'jane123', password: 'secret123', name: 'Jane Doe', school_id: school.id)
2525
end
2626

2727
it "makes a profile API call to create John Doe's account" do
2828
described_class.call(school:, uploaded_file: file, token:)
2929

3030
# TODO: Replace with WebMock assertion once the profile API has been built.
3131
expect(ProfileApiClient).to have_received(:create_school_student)
32-
.with(token:, username: 'john123', password: 'secret456', name: 'John Doe', organisation_id: school.id)
32+
.with(token:, username: 'john123', password: 'secret456', name: 'John Doe', school_id: school.id)
3333
end
3434

3535
context 'when an .xlsx file is provided' do

spec/concepts/school_student/create_spec.rb

+41-4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
RSpec.describe SchoolStudent::Create, type: :unit do
66
let(:token) { UserProfileMock::TOKEN }
77
let(:school) { create(:verified_school) }
8+
let(:user_id) { SecureRandom.uuid }
89

910
let(:school_student_params) do
1011
{
@@ -15,7 +16,7 @@
1516
end
1617

1718
before do
18-
stub_profile_api_create_school_student
19+
stub_profile_api_create_school_student(user_id:)
1920
end
2021

2122
it 'returns a successful operation response' do
@@ -28,13 +29,23 @@
2829

2930
# TODO: Replace with WebMock assertion once the profile API has been built.
3031
expect(ProfileApiClient).to have_received(:create_school_student)
31-
.with(token:, username: 'student-to-create', password: 'at-least-8-characters', name: 'School Student', organisation_id: school.id)
32+
.with(token:, username: 'student-to-create', password: 'at-least-8-characters', name: 'School Student', school_id: school.id)
33+
end
34+
35+
it 'creates a role associating the student with the school' do
36+
described_class.call(school:, school_student_params:, token:)
37+
expect(Role.student.where(school:, user_id:)).to exist
38+
end
39+
40+
it 'returns the ID of the student created in Profile API' do
41+
response = described_class.call(school:, school_student_params:, token:)
42+
expect(response[:student_id]).to eq(user_id)
3243
end
3344

3445
context 'when creation fails' do
3546
let(:school_student_params) do
3647
{
37-
username: ' ',
48+
username: '',
3849
password: 'at-least-8-characters',
3950
name: 'School Student'
4051
}
@@ -56,7 +67,7 @@
5667

5768
it 'returns the error message in the operation response' do
5869
response = described_class.call(school:, school_student_params:, token:)
59-
expect(response[:error]).to match(/username ' ' is invalid/)
70+
expect(response[:error]).to match(/username '' is invalid/)
6071
end
6172

6273
it 'sent the exception to Sentry' do
@@ -73,4 +84,30 @@
7384
expect(response[:error]).to match(/school is not verified/)
7485
end
7586
end
87+
88+
context 'when the student cannot be created in profile api because of a 422 response' do
89+
let(:error) { { 'username' => 'username', 'error' => 'ERR_USER_EXISTS' } }
90+
let(:exception) { ProfileApiClient::CreateStudent422Error.new(error) }
91+
92+
before do
93+
allow(ProfileApiClient).to receive(:create_school_student).and_raise(exception)
94+
end
95+
96+
it 'adds a useful error message' do
97+
response = described_class.call(school:, school_student_params:, token:)
98+
expect(response[:error]).to eq('Error creating school student: username has already been taken')
99+
end
100+
end
101+
102+
context 'when the student cannot be created in profile api because of a response other than 422' do
103+
before do
104+
allow(ProfileApiClient).to receive(:create_school_student)
105+
.and_raise('Student not created in Profile API (status code 401)')
106+
end
107+
108+
it 'adds a useful error message' do
109+
response = described_class.call(school:, school_student_params:, token:)
110+
expect(response[:error]).to eq('Error creating school student: Student not created in Profile API (status code 401)')
111+
end
112+
end
76113
end

spec/lib/profile_api_client_spec.rb

+99
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@
1212
allow(ENV).to receive(:fetch).with('PROFILE_API_KEY').and_return(api_key)
1313
end
1414

15+
describe ProfileApiClient::CreateStudent422Error do
16+
subject(:exception) { described_class.new(error) }
17+
18+
let(:error_code) { 'ERR_USER_EXISTS' }
19+
let(:error) { { 'username' => 'username', 'error' => error_code } }
20+
21+
it 'includes status code, username and translated error code in the message' do
22+
expect(exception.message).to eq("Student not created in Profile API (status code 422, username 'username', error 'username has already been taken')")
23+
end
24+
25+
context "when the error isn't recognised" do
26+
let(:error_code) { 'unrecognised-code' }
27+
28+
it 'includes a default error message' do
29+
expect(exception.message).to match(/error 'unknown error'/)
30+
end
31+
end
32+
end
33+
1534
describe '.create_school' do
1635
let(:school) { build(:school, id: SecureRandom.uuid, code: SecureRandom.uuid) }
1736
let(:create_school_url) { "#{api_url}/api/v1/schools" }
@@ -282,4 +301,84 @@ def delete_safeguarding_flag
282301
described_class.delete_safeguarding_flag(token:, flag:)
283302
end
284303
end
304+
305+
describe '.create_school_student' do
306+
let(:username) { 'username' }
307+
let(:password) { 'password' }
308+
let(:name) { 'name' }
309+
let(:school) { build(:school, id: SecureRandom.uuid) }
310+
let(:create_students_url) { "#{api_url}/api/v1/schools/#{school.id}/students" }
311+
312+
before do
313+
stub_request(:post, create_students_url).to_return(status: 201, body: '{}', headers: { 'content-type' => 'application/json' })
314+
end
315+
316+
it 'makes a request to the profile api host' do
317+
create_school_student
318+
expect(WebMock).to have_requested(:post, create_students_url)
319+
end
320+
321+
it 'includes token in the authorization request header' do
322+
create_school_student
323+
expect(WebMock).to have_requested(:post, create_students_url).with(headers: { authorization: "Bearer #{token}" })
324+
end
325+
326+
it 'includes the profile api key in the x-api-key request header' do
327+
create_school_student
328+
expect(WebMock).to have_requested(:post, create_students_url).with(headers: { 'x-api-key' => api_key })
329+
end
330+
331+
it 'sets content-type of request to json' do
332+
create_school_student
333+
expect(WebMock).to have_requested(:post, create_students_url).with(headers: { 'content-type' => 'application/json' })
334+
end
335+
336+
it 'sets accept header to json' do
337+
create_school_student
338+
expect(WebMock).to have_requested(:post, create_students_url).with(headers: { 'accept' => 'application/json' })
339+
end
340+
341+
it 'sends the student details in the request body' do
342+
create_school_student
343+
expect(WebMock).to have_requested(:post, create_students_url).with(body: [{ name:, username:, password: }].to_json)
344+
end
345+
346+
it 'returns the id of the created student(s) if successful' do
347+
response = { created: ['student-id'] }
348+
stub_request(:post, create_students_url)
349+
.to_return(status: 201, body: response.to_json, headers: { 'content-type' => 'application/json' })
350+
expect(create_school_student).to eq(response)
351+
end
352+
353+
it 'raises 422 exception if 422 status code is returned' do
354+
response = { errors: [username: 'username', error: 'ERR_USER_EXISTS'] }
355+
stub_request(:post, create_students_url)
356+
.to_return(status: 422, body: response.to_json, headers: { 'content-type' => 'application/json' })
357+
358+
expect { create_school_student }.to raise_error(ProfileApiClient::CreateStudent422Error)
359+
.with_message("Student not created in Profile API (status code 422, username 'username', error 'username has already been taken')")
360+
end
361+
362+
it 'raises exception if anything other that 201 status code is returned' do
363+
stub_request(:post, create_students_url)
364+
.to_return(status: 200)
365+
366+
expect { create_school_student }.to raise_error('Student not created in Profile API (status code 200)')
367+
end
368+
369+
context 'when there are extraneous leading and trailing spaces in the student params' do
370+
let(:username) { ' username ' }
371+
let(:password) { ' password ' }
372+
let(:name) { ' name ' }
373+
374+
it 'strips the extraneous spaces' do
375+
create_school_student
376+
expect(WebMock).to have_requested(:post, create_students_url).with(body: [{ name: 'name', username: 'username', password: 'password' }].to_json)
377+
end
378+
end
379+
380+
def create_school_student
381+
described_class.create_school_student(token:, username:, password:, name:, school_id: school.id)
382+
end
383+
end
285384
end

spec/support/profile_api_mock.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ def stub_profile_api_list_school_students(user_id:)
2727
allow(ProfileApiClient).to receive(:list_school_students).and_return(ids: [user_id])
2828
end
2929

30-
def stub_profile_api_create_school_student
31-
allow(ProfileApiClient).to receive(:create_school_student)
30+
def stub_profile_api_create_school_student(user_id: SecureRandom.uuid)
31+
allow(ProfileApiClient).to receive(:create_school_student).and_return(created: [user_id])
3232
end
3333

3434
def stub_profile_api_update_school_student

0 commit comments

Comments
 (0)