Files
codeocean/spec/controllers/exercises_controller_spec.rb
kiragrammel 175c8933f3 Automatically submit LTI grade on each score run
With this commit, we refactor the overall score handling of CodeOcean. Previously, "Score" and "Submit" were two distinct actions, requiring users to confirm the LTI transmission of their score (after assessing their submission). This yielded many questions and was unnecessary, since LTI parameters are no longer expiring after each use. Therefore, we can now transmit the current grade on each score run with the very same LTI parameters. As a consequence, the LTI consumer gets a more detailed history of the scores, enabling further analytical insights.

For users, the previous "Submit" button got replaced with a notification that is shown as soon as the full score got reached. Then, learners can decide to "finalize" their work on the given exercise, which will initiate a redirect to a follow-up action (as defined in the RedirectBehavior). This RedirectBehavior has also been unified and simplified for better readability.

As part of this refactoring, we rephrased the notifications and UX workflow of a) the LTI transmission, b) the finalization of an exercise (measured by reaching the full score) and c) the deadline handling (on time, within grace period, too late). Those information are now separately shown, potentially resulting in multiple notifications. As a side effect, they are much better maintainable, and the LTI transmission is more decoupled from this notification handling.
2023-11-23 14:42:10 +01:00

512 lines
17 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ExercisesController do
render_views
let(:exercise) { create(:dummy) }
let(:user) { create(:admin) }
before do
create(:test_file, context: exercise)
allow(controller).to receive(:current_user).and_return(user)
end
describe 'PUT #batch_update' do
let(:attributes) { ActionController::Parameters.new(public: 'true').permit! }
let(:perform_request) { proc { put :batch_update, params: {exercises: {0 => attributes.merge(id: exercise.id)}} } }
before { perform_request.call }
it 'updates the exercises' do
expect_any_instance_of(Exercise).to receive(:update).with(attributes)
perform_request.call
end
expect_json
expect_http_status(:ok)
end
describe 'POST #clone' do
let(:perform_request) { proc { post :clone, params: {id: exercise.id} } }
context 'when saving succeeds' do
before { perform_request.call }
expect_assigns(exercise: Exercise)
it 'clones the exercise' do
expect_any_instance_of(Exercise).to receive(:duplicate).with(hash_including(public: false, user:)).and_call_original
expect { perform_request.call }.to change(Exercise, :count).by(1)
end
it 'generates a new token' do
expect(Exercise.last.token).not_to eq(exercise.token)
end
expect_redirect(Exercise.last)
end
context 'when saving fails' do
before do
allow_any_instance_of(Exercise).to receive(:save).and_return(false)
perform_request.call
end
expect_assigns(exercise: Exercise)
expect_flash_message(:danger, :'shared.message_failure')
expect_redirect(:exercise)
end
end
describe 'POST #create' do
let(:exercise_attributes) { build(:dummy).attributes }
context 'with a valid exercise' do
let(:perform_request) { proc { post :create, params: {exercise: exercise_attributes} } }
before { perform_request.call }
expect_assigns(exercise: Exercise)
it 'creates the exercise' do
expect { perform_request.call }.to change(Exercise, :count).by(1)
end
expect_redirect(Exercise.last)
end
context 'when including a file' do
let(:perform_request) { proc { post :create, params: {exercise: exercise_attributes.merge(files_attributes:)} } }
context 'when specifying the file content within the form' do
let(:files_attributes) { {'0' => build(:file).attributes} }
it 'creates the file' do
expect { perform_request.call }.to change(CodeOcean::File, :count)
end
end
context 'when uploading a file' do
let(:files_attributes) { {'0' => build(:file, file_type:).attributes.merge(content: uploaded_file)} }
context 'when uploading a binary file' do
let(:file_path) { Rails.root.join('db/seeds/audio_video/devstories.mp4') }
let(:file_type) { create(:dot_mp4) }
let(:uploaded_file) { Rack::Test::UploadedFile.new(file_path, 'video/mp4', true) }
it 'creates the file' do
expect { perform_request.call }.to change(CodeOcean::File, :count)
end
it 'assigns the native file' do
perform_request.call
expect(Exercise.last.files.first.native_file).to be_a(FileUploader)
end
end
context 'when uploading a non-binary file' do
let(:file_path) { Rails.root.join('db/seeds/fibonacci/exercise.rb') }
let(:file_type) { create(:dot_rb) }
let(:uploaded_file) { Rack::Test::UploadedFile.new(file_path, 'text/x-ruby', false) }
it 'creates the file' do
expect { perform_request.call }.to change(CodeOcean::File, :count)
end
it 'assigns the file content' do
perform_request.call
expect(Exercise.last.files.first.content).to eq(File.read(file_path))
end
end
end
end
context 'with an invalid exercise' do
before { post :create, params: {exercise: {}} }
expect_assigns(exercise: Exercise)
expect_http_status(:ok)
expect_template(:new)
end
end
describe 'DELETE #destroy' do
before { delete :destroy, params: {id: exercise.id} }
expect_assigns(exercise: :exercise)
it 'destroys the exercise' do
exercise = create(:dummy)
expect { delete :destroy, params: {id: exercise.id} }.to change(Exercise, :count).by(-1)
end
expect_redirect(:exercises)
end
describe 'GET #edit' do
before { get :edit, params: {id: exercise.id} }
expect_assigns(exercise: :exercise)
expect_http_status(:ok)
expect_template(:edit)
end
describe 'GET #implement' do
let(:perform_request) { proc { get :implement, params: {id: exercise.id} } }
context 'with an exercise with visible files' do
let(:exercise) { create(:fibonacci) }
before { perform_request.call }
expect_assigns(exercise: :exercise)
context 'with an existing submission' do
let!(:submission) { create(:submission, exercise:, contributor: user) }
it "populates the editors with the submission's files' content" do
perform_request.call
expect(assigns(:files)).to eq(submission.files)
end
end
context 'without an existing submission' do
it "populates the editors with the exercise's files' content" do
expect(assigns(:files)).to eq(exercise.files.visible)
end
end
expect_http_status(:ok)
expect_template(:implement)
end
context 'with an exercise without visible files' do
before { perform_request.call }
expect_assigns(exercise: :exercise)
expect_flash_message(:alert, :'exercises.implement.no_files')
expect_redirect(:exercise)
end
context 'with other users accessing an unpublished exercise' do
let(:exercise) { create(:fibonacci, unpublished: true) }
let(:user) { create(:teacher) }
before { perform_request.call }
expect_assigns(exercise: :exercise)
expect_flash_message(:alert, :'exercises.implement.unpublished')
expect_redirect(:exercise)
end
end
describe 'GET #index' do
let(:scope) { Pundit.policy_scope!(user, Exercise) }
before do
create_pair(:dummy)
get :index
end
expect_assigns(exercises: :scope)
expect_http_status(:ok)
expect_template(:index)
end
describe 'GET #new' do
before { get :new }
expect_assigns(execution_environments: ExecutionEnvironment.all, exercise: Exercise)
expect_assigns(exercise: Exercise)
expect_http_status(:ok)
expect_template(:new)
end
describe 'GET #show' do
context 'when being admin' do
before { get :show, params: {id: exercise.id} }
expect_assigns(exercise: :exercise)
expect_http_status(:ok)
expect_template(:show)
end
end
describe 'GET #reload' do
context 'when being anyone' do
let(:exercise) { create(:fibonacci) }
before { get :reload, format: :json, params: {id: exercise.id} }
expect_assigns(exercise: :exercise)
expect_http_status(:ok)
expect_template(:reload)
end
end
describe 'GET #statistics' do
before { get :statistics, params: {id: exercise.id} }
expect_assigns(exercise: :exercise)
expect_http_status(:ok)
expect_template(:statistics)
end
describe 'GET #external_user_statistics' do
let(:perform_request) { get :external_user_statistics, params: }
let(:params) { {id: exercise.id, external_user_id: external_user.id} }
let(:external_user) { create(:external_user) }
before do
create_list(:submission, 2, cause: 'autosave', contributor: external_user, exercise:)
create_list(:submission, 2, cause: 'run', contributor: external_user, exercise:)
create(:submission, cause: 'assess', contributor: external_user, exercise:)
end
context 'when viewing the default submission statistics page without a parameter' do
it 'does not list autosaved submissions' do
perform_request
expect(assigns(:all_events).filter {|event| event.is_a? Submission }).to contain_exactly(
an_object_having_attributes(cause: 'run', contributor: external_user),
an_object_having_attributes(cause: 'assess', contributor: external_user),
an_object_having_attributes(cause: 'run', contributor: external_user)
)
end
end
context 'when including autosaved submissions via the query parameter' do
let(:params) { super().merge(show_autosaves: 'true') }
it 'lists all submissions, including autosaved submissions' do
perform_request
submissions = assigns(:all_events).filter {|event| event.is_a? Submission }
expect(submissions).to match_array Submission.all
expect(submissions).to include an_object_having_attributes(cause: 'autosave', contributor: external_user)
end
end
end
describe 'PUT #update' do
context 'with a valid exercise' do
let(:exercise_attributes) { build(:dummy).attributes }
before { put :update, params: {exercise: exercise_attributes, id: exercise.id} }
expect_assigns(exercise: Exercise)
expect_redirect(:exercise)
end
context 'with an invalid exercise' do
before { put :update, params: {exercise: {title: ''}, id: exercise.id} }
expect_assigns(exercise: Exercise)
expect_http_status(:ok)
expect_template(:edit)
end
end
RSpec::Matchers.define_negated_matcher :not_include, :include
# RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = 99999
describe 'POST #export_external_check' do
render_views
let(:post_request) { post :export_external_check, params: {id: exercise.id} }
let!(:codeharbor_link) { create(:codeharbor_link, user:) }
let(:external_check_hash) { {message:, uuid_found: true, update_right:, error:} }
let(:message) { 'message' }
let(:update_right) { true }
let(:error) { nil }
before { allow(ExerciseService::CheckExternal).to receive(:call).with(uuid: exercise.uuid, codeharbor_link:).and_return(external_check_hash) }
it 'renders the correct contents as json' do
post_request
expect(response.parsed_body.symbolize_keys[:message]).to eq('message')
expect(response.parsed_body.symbolize_keys[:actions]).to(
include('button').and(include('Abort').and(include('Export')))
)
expect(response.parsed_body.symbolize_keys[:actions]).to(
not_include('Retry').and(not_include('Hide'))
)
end
context 'when there is an error' do
let(:error) { 'error' }
it 'renders the correct contents as json' do
post_request
expect(response.parsed_body.symbolize_keys[:message]).to eq('message')
expect(response.parsed_body.symbolize_keys[:actions]).to(
include('button').and(include('Abort')).and(include('Retry'))
)
expect(response.parsed_body.symbolize_keys[:actions]).to(
not_include('Export').and(not_include('Hide'))
)
end
end
context 'when update_right is false' do
let(:update_right) { false }
it 'renders the correct contents as json' do
post_request
expect(response.parsed_body.symbolize_keys[:message]).to eq('message')
expect(response.parsed_body.symbolize_keys[:actions]).to(
include('button').and(include('Abort'))
)
expect(response.parsed_body.symbolize_keys[:actions]).to(
not_include('Retry').and(not_include('Export')).and(not_include('Hide'))
)
end
end
end
describe 'POST #export_external_confirm' do
render_views
let!(:codeharbor_link) { create(:codeharbor_link, user:) }
let(:post_request) { post :export_external_confirm, params: {id: exercise.id, codeharbor_link: codeharbor_link.id} }
let(:error) { nil }
let(:zip) { 'zip' }
before do
allow(ProformaService::ExportTask).to receive(:call).with(exercise:).and_return(zip)
allow(ExerciseService::PushExternal).to receive(:call).with(zip:, codeharbor_link:).and_return(error)
end
it 'renders correct response' do
post_request
expect(response).to have_http_status(:success)
expect(response.parsed_body.symbolize_keys[:message]).to(include('successfully exported'))
expect(response.parsed_body.symbolize_keys[:status]).to(eql('success'))
expect(response.parsed_body.symbolize_keys[:actions]).to(include('button').and(include('Close')))
expect(response.parsed_body.symbolize_keys[:actions]).to(not_include('Retry').and(not_include('Abort')))
end
context 'when an error occurs' do
let(:error) { 'exampleerror' }
it 'renders correct response' do
post_request
expect(response).to have_http_status(:success)
expect(response.parsed_body.symbolize_keys[:message]).to(include('failed').and(include('exampleerror')))
expect(response.parsed_body.symbolize_keys[:status]).to(eql('fail'))
expect(response.parsed_body.symbolize_keys[:actions]).to(include('button').and(include('Retry')).and(include('Close')))
expect(response.parsed_body.symbolize_keys[:actions]).to(not_include('Abort'))
end
end
end
describe 'POST #import_uuid_check' do
let(:exercise) { create(:dummy, uuid: SecureRandom.uuid) }
let!(:codeharbor_link) { create(:codeharbor_link, user:) }
let(:uuid) { exercise.reload.uuid }
let(:post_request) { post :import_uuid_check, params: {uuid:} }
let(:headers) { {'Authorization' => "Bearer #{codeharbor_link.api_key}"} }
before { request.headers.merge! headers }
it 'renders correct response' do
post_request
expect(response).to have_http_status(:success)
expect(response.parsed_body.symbolize_keys[:uuid_found]).to be true
expect(response.parsed_body.symbolize_keys[:update_right]).to be true
end
context 'when api_key is incorrect' do
let(:headers) { {'Authorization' => 'Bearer XXXXXX'} }
it 'renders correct response' do
post_request
expect(response).to have_http_status(:unauthorized)
end
end
context 'when the user cannot update the exercise' do
let(:codeharbor_link) { create(:codeharbor_link, api_key: 'anotherkey') }
it 'renders correct response' do
post_request
expect(response).to have_http_status(:success)
expect(response.parsed_body.symbolize_keys[:uuid_found]).to be true
expect(response.parsed_body.symbolize_keys[:update_right]).to be false
end
end
context 'when the searched exercise does not exist' do
let(:uuid) { 'anotheruuid' }
it 'renders correct response' do
post_request
expect(response).to have_http_status(:success)
expect(response.parsed_body.symbolize_keys[:uuid_found]).to be false
end
end
end
describe 'POST #import_task' do
let(:codeharbor_link) { create(:codeharbor_link, user:) }
let!(:imported_exercise) { create(:fibonacci) }
let(:post_request) { post :import_task, body: zip_file_content }
let(:zip_file_content) { 'zipped task xml' }
let(:headers) { {'Authorization' => "Bearer #{codeharbor_link.api_key}"} }
before do
request.headers.merge! headers
allow(ProformaService::Import).to receive(:call).and_return(imported_exercise)
end
it 'responds with correct status code' do
post_request
expect(response).to have_http_status(:created)
end
it 'calls service' do
post_request
expect(ProformaService::Import).to have_received(:call).with(zip: be_a(Tempfile).and(has_content(zip_file_content)), user:)
end
context 'when import fails with ProformaError' do
before { allow(ProformaService::Import).to receive(:call).and_raise(ProformaXML::PreImportValidationError) }
it 'responds with correct status code' do
post_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when import fails with ExerciseNotOwned' do
before { allow(ProformaService::Import).to receive(:call).and_raise(ProformaXML::ExerciseNotOwned) }
it 'responds with correct status code' do
post_request
expect(response).to have_http_status(:unauthorized)
end
end
context 'when import fails due to another error' do
before { allow(ProformaService::Import).to receive(:call).and_raise(StandardError) }
it 'responds with correct status code' do
post_request
expect(response).to have_http_status(:internal_server_error)
end
end
context 'when the imported exercise is invalid' do
before { allow(ProformaService::Import).to receive(:call) { imported_exercise.tap {|e| e.files = [] }.tap {|e| e.title = nil } } }
it 'responds with correct status code' do
expect { post_request }.not_to(change { imported_exercise.reload.files.count })
end
end
end
end