
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.
390 lines
13 KiB
Ruby
390 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
|
|
RSpec.describe SubmissionsController do
|
|
render_views
|
|
|
|
let(:exercise) { create(:math) }
|
|
let(:cause) { 'save' }
|
|
let(:submission) { create(:submission, exercise:, contributor:, cause:) }
|
|
|
|
shared_examples 'a regular user' do |record_not_found_status_code|
|
|
describe 'POST #create' do
|
|
before do
|
|
controller.request.accept = 'application/json'
|
|
end
|
|
|
|
context 'with a valid submission' do
|
|
let(:exercise) { create(:hello_world) }
|
|
let(:perform_request) { proc { post :create, format: :json, params: {submission: attributes_for(:submission, exercise_id: exercise.id)} } }
|
|
|
|
before { perform_request.call }
|
|
|
|
expect_assigns(submission: Submission)
|
|
|
|
it 'creates the submission' do
|
|
expect { perform_request.call }.to change(Submission, :count).by(1)
|
|
end
|
|
|
|
expect_json
|
|
expect_http_status(:created)
|
|
end
|
|
|
|
context 'with an invalid submission' do
|
|
before { post :create, params: {submission: {}} }
|
|
|
|
expect_assigns(submission: Submission)
|
|
expect_json
|
|
expect_http_status(:unprocessable_entity)
|
|
end
|
|
end
|
|
|
|
describe 'GET #download' do
|
|
let(:perform_request) { proc { get :download, params: {id: submission.id} } }
|
|
|
|
before { perform_request.call }
|
|
|
|
expect_assigns(submission: :submission)
|
|
expect_http_status(:ok)
|
|
end
|
|
|
|
describe 'GET #download_file' do
|
|
context 'with an invalid filename' do
|
|
before { get :download_file, params: {filename: SecureRandom.hex, id: submission.id, format: :json} }
|
|
|
|
expect_http_status(record_not_found_status_code)
|
|
end
|
|
|
|
context 'with a valid binary filename' do
|
|
let(:exercise) { create(:sql_select) }
|
|
let(:submission) { create(:submission, exercise:, contributor:) }
|
|
|
|
before { get :download_file, params: {filename: file.name_with_extension, id: submission.id} }
|
|
|
|
context 'with a binary file' do
|
|
let(:file) { submission.collect_files.detect {|file| file.name == 'exercise' && file.file_type.file_extension == '.sql' } }
|
|
|
|
expect_assigns(file: :file)
|
|
expect_assigns(submission: :submission)
|
|
expect_content_type('application/octet-stream')
|
|
expect_http_status(:ok)
|
|
|
|
it 'sets the correct filename' do
|
|
expect(response.headers['Content-Disposition']).to include("attachment; filename=\"#{file.name_with_extension}\"")
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with a valid filename' do
|
|
let(:exercise) { create(:audio_video) }
|
|
let(:submission) { create(:submission, exercise:, contributor:) }
|
|
|
|
before { get :download_file, params: {filename: file.name_with_extension, id: submission.id} }
|
|
|
|
context 'with a binary file' do
|
|
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.mp4' } }
|
|
|
|
expect_assigns(file: :file)
|
|
expect_assigns(submission: :submission)
|
|
|
|
it 'sets the correct redirect' do
|
|
expect(response.location).to eq protected_upload_url(id: file, filename: file.filepath)
|
|
end
|
|
end
|
|
|
|
context 'with a non-binary file' do
|
|
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.js' } }
|
|
|
|
expect_assigns(file: :file)
|
|
expect_assigns(submission: :submission)
|
|
expect_content_type('application/octet-stream')
|
|
expect_http_status(:ok)
|
|
|
|
it 'sets the correct filename' do
|
|
expect(response.headers['Content-Disposition']).to include("attachment; filename=\"#{file.name_with_extension}\"")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'GET #finalize' do
|
|
let(:perform_request) { proc { get :finalize, params: {id: submission.id} } }
|
|
let(:cause) { 'assess' }
|
|
|
|
context 'when the request is performed' do
|
|
before { perform_request.call }
|
|
|
|
expect_assigns(submission: :submission)
|
|
expect_redirect
|
|
end
|
|
|
|
it 'updates cause to submit' do
|
|
expect { perform_request.call && submission.reload }.to change(submission, :cause).from('assess').to('submit')
|
|
end
|
|
|
|
context 'when contributing to a community solution is possible' do
|
|
let!(:community_solution) { CommunitySolution.create(exercise:) }
|
|
|
|
before do
|
|
allow(Java21Study).to receive(:allow_redirect_to_community_solution?).and_return(true)
|
|
perform_request.call
|
|
end
|
|
|
|
expect_redirect { edit_community_solution_path(community_solution, lock_id: CommunitySolutionLock.last) }
|
|
end
|
|
|
|
context 'when sharing exercise feedback is desired' do
|
|
before do
|
|
uef&.save!
|
|
allow_any_instance_of(Submission).to receive(:redirect_to_feedback?).and_return(true)
|
|
perform_request.call
|
|
end
|
|
|
|
context 'without any previous feedback' do
|
|
let(:uef) { nil }
|
|
|
|
expect_redirect { new_user_exercise_feedback_path(user_exercise_feedback: {exercise_id: submission.exercise.id}) }
|
|
end
|
|
|
|
context 'with a previous feedback for the same exercise' do
|
|
let(:uef) { create(:user_exercise_feedback, exercise:, user: current_user) }
|
|
|
|
expect_redirect { edit_user_exercise_feedback_path(uef) }
|
|
end
|
|
end
|
|
|
|
context 'with an RfC' do
|
|
before do
|
|
rfc.save!
|
|
allow_any_instance_of(Submission).to receive(:redirect_to_feedback?).and_return(false)
|
|
perform_request.call
|
|
end
|
|
|
|
context 'when an own RfC is unsolved' do
|
|
let(:rfc) { create(:rfc, user: current_user, exercise:, submission:) }
|
|
|
|
expect_flash_message(:notice, I18n.t('exercises.editor.exercise_finished_redirect_to_own_rfc'))
|
|
expect_redirect { request_for_comment_url(rfc) }
|
|
end
|
|
|
|
context 'when another RfC is unsolved' do
|
|
let(:rfc) { create(:rfc, exercise:) }
|
|
|
|
expect_flash_message(:notice, I18n.t('exercises.editor.exercise_finished_redirect_to_rfc'))
|
|
expect_redirect { request_for_comment_url(rfc) }
|
|
end
|
|
end
|
|
|
|
context 'when neither a community solution, feedback nor RfC is available' do
|
|
before do
|
|
allow_any_instance_of(Submission).to receive(:redirect_to_feedback?).and_return(false)
|
|
perform_request.call
|
|
end
|
|
|
|
expect_redirect { lti_return_path(submission_id: submission.id) }
|
|
end
|
|
end
|
|
|
|
describe 'GET #render_file' do
|
|
let(:file) { submission.files.first }
|
|
let(:signed_url) { AuthenticatedUrlHelper.sign(render_submission_url(submission, filename), submission) }
|
|
let(:token) { Rack::Utils.parse_nested_query(URI.parse(signed_url).query)['token'] }
|
|
|
|
context 'with an invalid filename' do
|
|
let(:filename) { SecureRandom.hex }
|
|
|
|
before { get :render_file, params: {filename:, id: submission.id, token:} }
|
|
|
|
expect_http_status(record_not_found_status_code)
|
|
end
|
|
|
|
context 'with a valid filename' do
|
|
let(:exercise) { create(:audio_video) }
|
|
let(:submission) { create(:submission, exercise:, contributor:) }
|
|
let(:filename) { file.name_with_extension }
|
|
|
|
before { get :render_file, params: {filename:, id: submission.id, token:} }
|
|
|
|
context 'with a binary file' do
|
|
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.mp4' } }
|
|
let(:signed_url_video) { AuthenticatedUrlHelper.sign(render_protected_upload_url(id: file, filename: file.filepath), file) }
|
|
|
|
expect_assigns(file: :file)
|
|
expect_assigns(submission: :submission)
|
|
|
|
it 'sets the correct redirect' do
|
|
expect(response.location).to eq signed_url_video
|
|
end
|
|
end
|
|
|
|
context 'with a non-binary file' do
|
|
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.js' } }
|
|
|
|
expect_assigns(file: :file)
|
|
expect_assigns(submission: :submission)
|
|
expect_content_type('text/javascript')
|
|
expect_http_status(:ok)
|
|
|
|
it 'renders the file content' do
|
|
expect(response.body).to eq(file.content)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'GET #run' do
|
|
let(:file) { submission.collect_files.detect(&:main_file?) }
|
|
let(:perform_request) { get :run, format: :json, params: {filename: file.filepath, id: submission.id} }
|
|
|
|
context 'when no errors occur during execution' do
|
|
before do
|
|
allow_any_instance_of(described_class).to receive(:hijack)
|
|
allow_any_instance_of(described_class).to receive(:close_client_connection)
|
|
allow_any_instance_of(Submission).to receive(:run).and_return({})
|
|
allow_any_instance_of(described_class).to receive(:save_testrun_output)
|
|
perform_request
|
|
end
|
|
|
|
expect_assigns(submission: :submission)
|
|
expect_assigns(file: :file)
|
|
expect_http_status(204)
|
|
end
|
|
end
|
|
|
|
describe 'GET #score' do
|
|
let(:perform_request) { proc { get :score, format: :json, params: {id: submission.id} } }
|
|
|
|
before do
|
|
allow_any_instance_of(described_class).to receive(:hijack)
|
|
allow_any_instance_of(described_class).to receive(:kill_client_socket)
|
|
perform_request.call
|
|
end
|
|
|
|
expect_assigns(submission: :submission)
|
|
expect_http_status(204)
|
|
end
|
|
|
|
describe 'GET #show' do
|
|
before { get :show, params: {id: submission.id} }
|
|
|
|
expect_assigns(submission: :submission)
|
|
expect_http_status(:ok)
|
|
expect_template(:show)
|
|
end
|
|
|
|
describe 'GET #show.json' do
|
|
before { get :show, params: {id: submission.id}, format: :json }
|
|
|
|
expect_assigns(submission: :submission)
|
|
expect_http_status(:ok)
|
|
|
|
%i[run test].each do |action|
|
|
describe "##{action}_url" do
|
|
let(:url) { response.parsed_body.with_indifferent_access.fetch("#{action}_url") }
|
|
|
|
it "starts like the #{action} path" do
|
|
filename = File.basename(__FILE__)
|
|
expect(url).to start_with(Rails.application.routes.url_helpers.send(:"#{action}_submission_path", submission, filename).sub(filename, ''))
|
|
end
|
|
|
|
it 'ends with a placeholder' do
|
|
expect(url).to end_with("#{Submission::FILENAME_URL_PLACEHOLDER}.json")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#render_url' do
|
|
let(:supported_urls) { response.parsed_body.with_indifferent_access.fetch('render_url') }
|
|
let(:file) { submission.collect_files.detect(&:main_file?) }
|
|
let(:url) { supported_urls.find {|hash| hash[:filepath] == file.filepath }['url'] }
|
|
|
|
it 'starts like the render path' do
|
|
expect(url).to start_with(Rails.application.routes.url_helpers.render_submission_url(submission, file.filepath, host: request.host))
|
|
end
|
|
|
|
it 'includes a token' do
|
|
expect(url).to include '?token='
|
|
end
|
|
end
|
|
|
|
describe '#score_url' do
|
|
let(:url) { response.parsed_body.with_indifferent_access.fetch('score_url') }
|
|
|
|
it 'corresponds to the score path' do
|
|
expect(url).to eq(Rails.application.routes.url_helpers.score_submission_path(submission, format: :json))
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'GET #test' do
|
|
let(:file) { submission.collect_files.detect(&:teacher_defined_assessment?) }
|
|
let(:output) { {} }
|
|
|
|
before do
|
|
file.update(hidden: false)
|
|
allow_any_instance_of(described_class).to receive(:hijack)
|
|
allow_any_instance_of(described_class).to receive(:kill_client_socket)
|
|
get :test, params: {filename: "#{file.filepath}.json", id: submission.id}
|
|
end
|
|
|
|
expect_assigns(submission: :submission)
|
|
expect_assigns(file: :file)
|
|
expect_http_status(204)
|
|
end
|
|
end
|
|
|
|
shared_examples 'denies access for regular, non-admin users' do # rubocop:disable RSpec/SharedContext
|
|
describe 'GET #index' do
|
|
before do
|
|
create_pair(:submission, contributor:, exercise:)
|
|
get :index
|
|
end
|
|
|
|
expect_redirect(:root)
|
|
end
|
|
end
|
|
|
|
context 'with an admin user' do
|
|
let(:contributor) { create(:admin) }
|
|
let(:current_user) { contributor }
|
|
|
|
before { allow(controller).to receive_messages(current_user:) }
|
|
|
|
describe 'GET #index' do
|
|
before do
|
|
create_pair(:submission, contributor:, exercise:)
|
|
get :index
|
|
end
|
|
|
|
expect_assigns(submissions: Submission.all)
|
|
expect_http_status(:ok)
|
|
expect_template(:index)
|
|
end
|
|
|
|
it_behaves_like 'a regular user', :not_found
|
|
end
|
|
|
|
context 'with a programming group' do
|
|
let(:group_author) { create(:external_user) }
|
|
let(:other_group_author) { create(:external_user) }
|
|
let(:contributor) { create(:programming_group, exercise:, users: [group_author, other_group_author]) }
|
|
let(:current_user) { group_author }
|
|
|
|
before { allow(controller).to receive_messages(current_contributor: contributor, current_user:) }
|
|
|
|
it_behaves_like 'a regular user', :unauthorized
|
|
it_behaves_like 'denies access for regular, non-admin users'
|
|
end
|
|
|
|
context 'with a learner' do
|
|
let(:contributor) { create(:external_user) }
|
|
let(:current_user) { contributor }
|
|
|
|
before { allow(controller).to receive_messages(current_user:) }
|
|
|
|
it_behaves_like 'a regular user', :unauthorized
|
|
it_behaves_like 'denies access for regular, non-admin users'
|
|
end
|
|
end
|