
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.
512 lines
17 KiB
Ruby
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
|