diff --git a/Gemfile b/Gemfile index 4bdc5e8d..6ca05b58 100644 --- a/Gemfile +++ b/Gemfile @@ -72,6 +72,7 @@ group :test do gem 'database_cleaner' gem 'nyan-cat-formatter' gem 'rspec-autotest' + gem 'rspec-collection_matchers' gem 'rspec-rails' gem 'shoulda-matchers' gem 'simplecov', require: false diff --git a/Gemfile.lock b/Gemfile.lock index e3afa6a6..8ba86613 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -318,6 +318,8 @@ GEM rspec-mocks (~> 3.9.0) rspec-autotest (1.0.2) rspec-core (>= 2.99.0.beta1, < 4.0.0) + rspec-collection_matchers (1.1.3) + rspec-expectations (>= 2.99.0.beta1) rspec-core (3.9.0) rspec-support (~> 3.9.0) rspec-expectations (3.9.0) @@ -486,6 +488,7 @@ DEPENDENCIES ransack rest-client rspec-autotest + rspec-collection_matchers rspec-rails rubocop rubocop-rspec diff --git a/app/services/exercise_service/check_external.rb b/app/services/exercise_service/check_external.rb index 1aac945b..a7add85d 100644 --- a/app/services/exercise_service/check_external.rb +++ b/app/services/exercise_service/check_external.rb @@ -16,8 +16,8 @@ module ExerciseService response_hash = JSON.parse(response.body, symbolize_names: true) {error: false}.merge(response_hash.slice(:message, :exercise_found, :update_right)) - rescue Faraday::Error => e - {error: true, message: I18n.t('exercises.export_codeharbor.error', message: e.message)} + rescue Faraday::Error, JSON::ParserError + {error: true, message: I18n.t('exercises.export_codeharbor.error')} end private diff --git a/app/services/exercise_service/push_external.rb b/app/services/exercise_service/push_external.rb index 93dacb81..3410a7ba 100644 --- a/app/services/exercise_service/push_external.rb +++ b/app/services/exercise_service/push_external.rb @@ -17,9 +17,9 @@ module ExerciseService request.body = body end - return response.success? ? nil : response.body + response.success? ? nil : response.body rescue StandardError => e - return e.message + e.message end end diff --git a/app/services/proforma_service/convert_exercise_to_task.rb b/app/services/proforma_service/convert_exercise_to_task.rb index e22aa709..7a44c5aa 100644 --- a/app/services/proforma_service/convert_exercise_to_task.rb +++ b/app/services/proforma_service/convert_exercise_to_task.rb @@ -22,11 +22,9 @@ module ProformaService title: @exercise.title, description: @exercise.description, internal_description: @exercise.instructions, - # proglang: proglang, files: task_files, tests: tests, uuid: uuid, - # parent_uuid: parent_uuid, language: DEFAULT_LANGUAGE, model_solutions: model_solutions, import_checksum: @exercise.import_checksum @@ -66,8 +64,6 @@ module ProformaService files: test_file(file), meta_data: { 'feedback-message' => file.feedback_message - # 'testing-framework' => test.testing_framework&.name, - # 'testing-framework-version' => test.testing_framework&.version }.compact ) end diff --git a/app/services/proforma_service/convert_task_to_exercise.rb b/app/services/proforma_service/convert_task_to_exercise.rb index 0264494f..f87a9354 100644 --- a/app/services/proforma_service/convert_task_to_exercise.rb +++ b/app/services/proforma_service/convert_task_to_exercise.rb @@ -22,7 +22,6 @@ module ProformaService description: @task.description, instructions: @task.internal_description, files: files, - uuid: @task.uuid, import_checksum: @task.checksum ) end @@ -56,10 +55,10 @@ module ProformaService name: File.basename(file.filename, '.*'), read_only: file.usage_by_lms != 'edit', role: file.internal_description, - path: File.dirname(file.filename) + path: File.dirname(file.filename).in?(['.', '']) ? nil : File.dirname(file.filename) }.tap do |params| if file.binary - params[:native_file] = FileIO.new(file.content.force_encoding('UTF-8'), File.basename(file.filename)) + params[:native_file] = FileIO.new(file.content.dup.force_encoding('UTF-8'), File.basename(file.filename)) else params[:content] = file.content end diff --git a/app/services/proforma_service/import.rb b/app/services/proforma_service/import.rb index b8ec628b..4fa0676e 100644 --- a/app/services/proforma_service/import.rb +++ b/app/services/proforma_service/import.rb @@ -12,7 +12,7 @@ module ProformaService importer = Proforma::Importer.new(@zip) @task = importer.perform - exercise = Exercise.find_by(uuid: @task.uuid) + exercise = base_exercise exercise_files = exercise&.files&.to_a exercise = ConvertTaskToExercise.call(task: @task, user: @user, exercise: exercise) @@ -26,6 +26,17 @@ module ProformaService private + def base_exercise + exercise = Exercise.find_by(uuid: @task.uuid) + if exercise + return exercise if ExercisePolicy.new(@user, exercise).update? + + return Exercise.new(uuid: SecureRandom.uuid, unpublished: true) + end + + Exercise.new(uuid: @task.uuid || SecureRandom.uuid, unpublished: true) + end + def import_multi Zip::File.open(@zip.path) do |zip_file| zip_files = zip_file.filter { |entry| entry.name.match?(/\.zip$/) } diff --git a/config/locales/en.yml b/config/locales/en.yml index 8a0707e1..6211c3e1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -329,7 +329,7 @@ en: dialogtitle: Export to Codeharbor successfully_exported: 'Exercise has been successfully exported.
ID: %{id}
Title: %{title}' export_failed: 'Export has failed.
ID: %{id}
Title: %{title}

Error: %{error}' - error: 'An error occurred while contacting Codeharbor
Error: %{message}' + error: 'An error occurred while contacting Codeharbor' checking_codeharbor: Checking if the exercise exists on Codeharbor. buttons: retry: Retry diff --git a/spec/factories/code_ocean/file.rb b/spec/factories/code_ocean/file.rb index 186ad1d1..64245e97 100644 --- a/spec/factories/code_ocean/file.rb +++ b/spec/factories/code_ocean/file.rb @@ -10,6 +10,24 @@ module CodeOcean name { SecureRandom.hex } read_only { false } role { 'main_file' } + + trait(:image) do + association :file_type, factory: :dot_png + name { 'poster' } + native_file { Rack::Test::UploadedFile.new(Rails.root.join('db', 'seeds', 'audio_video', 'poster.png'), 'image/png') } + end + end + + factory :test_file, class: CodeOcean::File do + content { '' } + association :context, factory: :dummy + association :file_type, factory: :dot_rb + hidden { true } + name { SecureRandom.hex } + read_only { true } + role { 'teacher_defined_test' } + feedback_message { 'feedback_message' } + weight { 1 } end end end diff --git a/spec/services/exercise_service/check_external_spec.rb b/spec/services/exercise_service/check_external_spec.rb new file mode 100644 index 00000000..ea7874c6 --- /dev/null +++ b/spec/services/exercise_service/check_external_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ExerciseService::CheckExternal do + describe '.new' do + subject(:export_service) { described_class.new(uuid: uuid, codeharbor_link: codeharbor_link) } + + let(:uuid) { SecureRandom.uuid } + let(:codeharbor_link) { FactoryBot.build(:codeharbor_link) } + + it 'assigns uuid' do + expect(export_service.instance_variable_get(:@uuid)).to be uuid + end + + it 'assigns codeharbor_link' do + expect(export_service.instance_variable_get(:@codeharbor_link)).to be codeharbor_link + end + end + + describe '#execute' do + subject(:check_external_service) { described_class.call(uuid: uuid, codeharbor_link: codeharbor_link) } + + let(:uuid) { SecureRandom.uuid } + let(:codeharbor_link) { FactoryBot.build(:codeharbor_link) } + let(:response) { {}.to_json } + + before { stub_request(:post, codeharbor_link.check_uuid_url).to_return(body: response) } + + it 'calls the correct url' do + expect(check_external_service).to have_requested(:post, codeharbor_link.check_uuid_url) + end + + it 'submits the correct headers' do + expect(check_external_service).to have_requested(:post, codeharbor_link.check_uuid_url) + .with(headers: {content_type: 'application/json', authorization: "Bearer #{codeharbor_link.api_key}"}) + end + + it 'submits the correct body' do + expect(check_external_service).to have_requested(:post, codeharbor_link.check_uuid_url) + .with(body: {uuid: uuid}.to_json) + end + + context 'when response contains a JSON with expected keys' do + let(:response) { {message: 'message', exercise_found: true, update_right: true}.to_json } + + it 'returns the correct hash' do + expect(check_external_service).to eql(error: false, message: 'message', exercise_found: true, update_right: true) + end + + context 'with different values' do + let(:response) { {message: 'message', exercise_found: false, update_right: false}.to_json } + + it 'returns the correct hash' do + expect(check_external_service).to eql(error: false, message: 'message', exercise_found: false, update_right: false) + end + end + end + + context 'when response does not contain JSON' do + let(:response) { 'foo' } + + it 'returns the correct hash' do + expect(check_external_service).to eql(error: true, message: I18n.t('exercises.export_codeharbor.error')) + end + end + + context 'when the request fails' do + before { allow(Faraday).to receive(:new).and_raise(Faraday::Error, 'error') } + + it 'returns the correct hash' do + expect(check_external_service).to eql(error: true, message: I18n.t('exercises.export_codeharbor.error')) + end + end + end +end diff --git a/spec/services/exercise_service/push_external_spec.rb b/spec/services/exercise_service/push_external_spec.rb new file mode 100644 index 00000000..20c07a0a --- /dev/null +++ b/spec/services/exercise_service/push_external_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ExerciseService::PushExternal do + describe '.new' do + subject(:push_external) { described_class.new(zip: zip, codeharbor_link: codeharbor_link) } + + let(:zip) { ProformaService::ExportTask.call(exercise: FactoryBot.build(:dummy)) } + let(:codeharbor_link) { FactoryBot.build(:codeharbor_link) } + + it 'assigns zip' do + expect(push_external.instance_variable_get(:@zip)).to be zip + end + + it 'assigns codeharbor_link' do + expect(push_external.instance_variable_get(:@codeharbor_link)).to be codeharbor_link + end + end + + describe '#execute' do + subject(:push_external) { described_class.call(zip: zip, codeharbor_link: codeharbor_link) } + + let(:zip) { ProformaService::ExportTask.call(exercise: FactoryBot.build(:dummy)) } + let(:codeharbor_link) { FactoryBot.build(:codeharbor_link) } + let(:status) { 200 } + let(:response) { '' } + + before { stub_request(:post, codeharbor_link.push_url).to_return(status: status, body: response) } + + it 'calls the correct url' do + expect(push_external).to have_requested(:post, codeharbor_link.push_url) + end + + it 'submits the correct headers' do + expect(push_external).to have_requested(:post, codeharbor_link.push_url) + .with(headers: {content_type: 'application/zip', + authorization: "Bearer #{codeharbor_link.api_key}", + content_length: zip.string.length}) + end + + it 'submits the correct body' do + expect(push_external).to have_requested(:post, codeharbor_link.push_url) + .with(body: zip.string) + end + + context 'when response status is success' do + it { is_expected.to be nil } + + context 'when response status is 500' do + let(:status) { 500 } + let(:response) { 'an error occured' } + + it { is_expected.to be response } + end + end + + context 'when an error occurs' do + before { allow(Faraday).to receive(:new).and_raise(StandardError) } + + it { is_expected.not_to be nil } + end + end +end diff --git a/spec/services/proforma_service/convert_exercise_to_task_spec.rb b/spec/services/proforma_service/convert_exercise_to_task_spec.rb new file mode 100644 index 00000000..ae2976d1 --- /dev/null +++ b/spec/services/proforma_service/convert_exercise_to_task_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ProformaService::ConvertExerciseToTask do + describe '.new' do + subject(:convert_to_task) { described_class.new(exercise: exercise) } + + let(:exercise) { FactoryBot.build(:dummy) } + + it 'assigns exercise' do + expect(convert_to_task.instance_variable_get(:@exercise)).to be exercise + end + end + + describe '#execute' do + subject(:task) { convert_to_task.execute } + + let(:convert_to_task) { described_class.new(exercise: exercise) } + let(:exercise) do + FactoryBot.create(:dummy, + instructions: 'instruction', + uuid: SecureRandom.uuid, + files: files + tests) + end + let(:files) { [] } + let(:tests) { [] } + + it 'creates a task with all basic attributes' do + expect(task).to have_attributes( + title: exercise.title, + description: exercise.description, + internal_description: exercise.instructions, + # proglang: { + # name: exercise.execution_environment.language, + # version: exercise.execution_environment.version + # }, + uuid: exercise.uuid, + language: described_class::DEFAULT_LANGUAGE, + # parent_uuid: exercise.clone_relations.first&.origin&.uuid, + files: [], + tests: [], + model_solutions: [], + import_checksum: exercise.import_checksum + ) + end + + context 'when exercise has a mainfile' do + let(:files) { [file] } + let(:file) { FactoryBot.build(:file) } + + it 'creates a task-file with the correct attributes' do + expect(task.files.first).to have_attributes( + id: file.id, + content: file.content, + filename: file.name_with_extension, + used_by_grader: true, + usage_by_lms: 'edit', + visible: 'yes', + binary: false, + internal_description: 'main_file' + ) + end + end + + context 'when exercise has a regular file' do + let(:files) { [file] } + let(:file) { FactoryBot.build(:file, role: 'regular_file', hidden: hidden, read_only: read_only) } + let(:hidden) { true } + let(:read_only) { true } + + it 'creates a task-file with the correct attributes' do + expect(task.files.first).to have_attributes( + id: file.id, + content: file.content, + filename: file.name_with_extension, + used_by_grader: true, + usage_by_lms: 'display', + visible: 'no', + binary: false, + internal_description: 'regular_file' + ) + end + + context 'when file is not hidden' do + let(:hidden) { false } + + it 'creates a task-file with the correct attributes' do + expect(task.files.first).to have_attributes(visible: 'yes') + end + end + + context 'when file is not read_only' do + let(:read_only) { false } + + it 'creates a task-file with the correct attributes' do + expect(task.files.first).to have_attributes(usage_by_lms: 'edit') + end + end + + context 'when file has an attachment' do + let(:file) { FactoryBot.build(:file, :image, role: 'regular_file') } + + it 'creates a task-file with the correct attributes' do + expect(task.files.first).to have_attributes( + used_by_grader: false, + binary: true, + mimetype: 'image/png' + ) + end + end + end + + context 'when exercise has a file with role reference implementation' do + let(:files) { [file] } + let(:file) { FactoryBot.build(:file, role: 'reference_implementation') } + + it 'creates a task with one model-solution' do + expect(task.model_solutions).to have(1).item + end + + it 'creates a model-solution with one file' do + expect(task.model_solutions.first).to have_attributes( + id: "ms-#{file.id}", + files: have(1).item + ) + end + + it 'creates a model-solution with one file with correct attributes' do + expect(task.model_solutions.first.files.first).to have_attributes( + id: file.id, + content: file.content, + filename: file.name_with_extension, + used_by_grader: false, + usage_by_lms: 'display', + visible: 'delayed', + binary: false, + internal_description: 'reference_implementation' + ) + end + end + + context 'when exercise has multiple files with role reference implementation' do + let(:files) { FactoryBot.build_list(:file, 2, role: 'reference_implementation') } + + it 'creates a task with two model-solutions' do + expect(task.model_solutions).to have(2).items + end + end + + context 'when exercise has a test' do + let(:tests) { [test_file] } + let(:test_file) { FactoryBot.build(:test_file) } + # let(:file) { FactoryBot.build(:codeharbor_test_file) } + + it 'creates a task with one test' do + expect(task.tests).to have(1).item + end + + it 'creates a test with one file' do + expect(task.tests.first).to have_attributes( + id: test_file.id, + title: test_file.name, + files: have(1).item, + meta_data: {'feedback-message' => test_file.feedback_message} + ) + end + + it 'creates a test with one file with correct attributes' do + expect(task.tests.first.files.first).to have_attributes( + id: test_file.id, + content: test_file.content, + filename: test_file.name_with_extension, + used_by_grader: true, + visible: 'no', + binary: false, + internal_description: 'teacher_defined_test' + ) + end + + context 'when exercise_file is not hidden' do + let(:test_file) { FactoryBot.create(:test_file, hidden: false) } + + it 'creates the test file with the correct attribute' do + expect(task.tests.first.files.first).to have_attributes(visible: 'yes') + end + end + end + + context 'when exercise has multiple tests' do + let(:tests) { FactoryBot.build_list(:test_file, 2) } + + it 'creates a task with two tests' do + expect(task.tests).to have(2).items + end + end + end +end diff --git a/spec/services/proforma_service/convert_task_to_exercise_spec.rb b/spec/services/proforma_service/convert_task_to_exercise_spec.rb new file mode 100644 index 00000000..4f0a3b8c --- /dev/null +++ b/spec/services/proforma_service/convert_task_to_exercise_spec.rb @@ -0,0 +1,377 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ProformaService::ConvertTaskToExercise do + describe '.new' do + subject(:convert_to_exercise_service) { described_class.new(task: task, user: user, exercise: exercise) } + + let(:task) { Proforma::Task.new } + let(:user) { FactoryBot.build(:teacher) } + let(:exercise) { FactoryBot.build(:dummy) } + + it 'assigns task' do + expect(convert_to_exercise_service.instance_variable_get(:@task)).to be task + end + + it 'assigns user' do + expect(convert_to_exercise_service.instance_variable_get(:@user)).to be user + end + + it 'assigns exercise' do + expect(convert_to_exercise_service.instance_variable_get(:@exercise)).to be exercise + end + end + + describe '#execute' do + subject(:convert_to_exercise_service) { described_class.call(task: task, user: user, exercise: exercise) } + + before { FactoryBot.create(:dot_txt) } + + let(:task) do + Proforma::Task.new( + title: 'title', + description: 'description', + internal_description: 'internal_description', + proglang: {name: 'proglang-name', version: 'proglang-version'}, + uuid: 'uuid', + parent_uuid: 'parent_uuid', + language: 'language', + model_solutions: model_solutions, + files: files, + tests: tests, + import_checksum: 'import_checksum', + checksum: 'checksum' + ) + end + let(:user) { FactoryBot.create(:teacher) } + let(:files) { [] } + let(:tests) { [] } + let(:model_solutions) { [] } + let(:exercise) {} + + it 'creates an exercise with the correct attributes' do + expect(convert_to_exercise_service).to have_attributes( + title: 'title', + description: 'description', + instructions: 'internal_description', + execution_environment: be_blank, + uuid: be_blank, + unpublished: true, + user: user, + files: be_empty + ) + end + + context 'when task has a file' do + let(:files) { [file] } + let(:file) do + Proforma::TaskFile.new( + id: 'id', + content: content, + filename: 'filename.txt', + used_by_grader: 'used_by_grader', + visible: 'yes', + usage_by_lms: usage_by_lms, + binary: binary, + internal_description: 'regular_file', + mimetype: mimetype + ) + end + let(:usage_by_lms) { 'display' } + let(:mimetype) { 'mimetype' } + let(:binary) { false } + let(:content) { 'content' } + + it 'creates an exercise with a file that has the correct attributes' do + expect(convert_to_exercise_service.files.first).to have_attributes( + content: 'content', + name: 'filename', + role: 'regular_file', + hidden: false, + read_only: true, + file_type: be_a(FileType).and(have_attributes(file_extension: '.txt')) + ) + end + + it 'creates a new Exercise on save' do + expect { convert_to_exercise_service.save! }.to change(Exercise, :count).by(1) + end + + context 'when file is very large' do + let(:content) { 'test' * 10**5 } + + it 'creates an exercise with a file that has the correct attributes' do + expect(convert_to_exercise_service.files.first).to have_attributes(content: content) + end + end + + context 'when file is binary' do + let(:mimetype) { 'image/png' } + let(:binary) { true } + + it 'creates an exercise with a file with attachment and the correct attributes' do + expect(convert_to_exercise_service.files.first.native_file).to be_present + end + end + + context 'when usage_by_lms is edit' do + let(:usage_by_lms) { 'edit' } + + it 'creates an exercise with a file with correct attributes' do + expect(convert_to_exercise_service.files.first).to have_attributes(read_only: false) + end + end + + context 'when file is a model-solution-placeholder (needed by proforma until issue #5 is resolved)' do + let(:file) { Proforma::TaskFile.new(id: 'ms-placeholder-file') } + + it 'leaves exercise_files empty' do + expect(convert_to_exercise_service.files).to be_empty + end + end + end + + context 'when task has a model-solution' do + let(:model_solutions) { [model_solution] } + let(:model_solution) do + Proforma::ModelSolution.new( + id: 'ms-id', + files: ms_files + ) + end + let(:ms_files) { [ms_file] } + let(:ms_file) do + Proforma::TaskFile.new( + id: 'ms-file', + content: 'content', + filename: 'filename.txt', + used_by_grader: 'used_by_grader', + visible: 'yes', + usage_by_lms: 'display', + binary: false, + internal_description: 'reference_implementation' + ) + end + + it 'creates an exercise with a file with role Reference Implementation' do + expect(convert_to_exercise_service.files.first).to have_attributes( + role: 'reference_implementation' + ) + end + + context 'when task has two model-solutions' do + let(:model_solutions) { [model_solution, model_solution2] } + let(:model_solution2) do + Proforma::ModelSolution.new( + id: 'ms-id-2', + files: ms_files_2 + ) + end + let(:ms_files_2) { [ms_file_2] } + let(:ms_file_2) do + Proforma::TaskFile.new( + id: 'ms-file-2', + content: 'content', + filename: 'filename.txt', + used_by_grader: 'used_by_grader', + visible: 'yes', + usage_by_lms: 'display', + binary: false, + internal_description: 'reference_implementation' + ) + end + + it 'creates an exercise with two files with role Reference Implementation' do + expect(convert_to_exercise_service.files).to have(2).items.and(all(have_attributes(role: 'reference_implementation'))) + end + end + end + + context 'when task has a test' do + let(:tests) { [test] } + let(:test) do + Proforma::Test.new( + id: 'test-id', + title: 'title', + description: 'description', + internal_description: 'internal_description', + test_type: 'test_type', + files: test_files, + meta_data: { + 'feedback-message' => 'feedback-message', + 'testing-framework' => 'testing-framework', + 'testing-framework-version' => 'testing-framework-version' + } + ) + end + + let(:test_files) { [test_file] } + let(:test_file) do + Proforma::TaskFile.new( + id: 'test_file_id', + content: 'testfile-content', + filename: 'testfile.txt', + used_by_grader: 'yes', + visible: 'no', + usage_by_lms: 'display', + binary: false, + internal_description: 'teacher_defined_test' + ) + end + + it 'creates an exercise with a test' do + expect(convert_to_exercise_service.files.select { |file| file.role == 'teacher_defined_test' }).to have(1).item + end + + it 'creates an exercise with a test with correct attributes' do + expect(convert_to_exercise_service.files.select { |file| file.role == 'teacher_defined_test' }.first).to have_attributes( + feedback_message: 'feedback-message', + content: 'testfile-content', + name: 'testfile', + role: 'teacher_defined_test', + hidden: true, + read_only: true, + file_type: be_a(FileType).and(have_attributes(file_extension: '.txt')) + ) + end + + context 'when task has multiple tests' do + let(:tests) { [test, test2] } + let(:test2) do + Proforma::Test.new( + files: test_files2, + meta_data: { + 'feedback-message' => 'feedback-message', + 'testing-framework' => 'testing-framework', + 'testing-framework-version' => 'testing-framework-version' + } + ) + end + let(:test_files2) { [test_file2] } + let(:test_file2) do + Proforma::TaskFile.new( + id: 'test_file_id2', + content: 'testfile-content', + filename: 'testfile.txt', + used_by_grader: 'yes', + visible: 'no', + usage_by_lms: 'display', + binary: false, + internal_description: 'teacher_defined_test' + ) + end + + it 'creates an exercise with two test' do + expect(convert_to_exercise_service.files.select { |file| file.role == 'teacher_defined_test' }).to have(2).items + end + end + end + + context 'when exercise is set' do + let(:exercise) do + FactoryBot.create( + :files, + title: 'exercise-title', + description: 'exercise-description', + instructions: 'exercise-instruction' + ) + end + + before { exercise.reload } + + it 'assigns all values to given exercise' do + convert_to_exercise_service.save + expect(exercise.reload).to have_attributes( + id: exercise.id, + title: task.title, + description: task.description, + instructions: task.internal_description, + execution_environment: exercise.execution_environment, + uuid: exercise.uuid, + user: exercise.user, + files: be_empty + ) + end + + it 'does not create a new Exercise on save' do + expect { convert_to_exercise_service.save }.not_to change(Exercise, :count) + end + + context 'with file, model solution and test' do + let(:files) { [file] } + let(:file) do + Proforma::TaskFile.new( + id: 'id', + content: 'content', + filename: 'filename.txt', + used_by_grader: 'used_by_grader', + visible: 'yes', + usage_by_lms: 'display', + binary: false, + internal_description: 'regular_file' + ) + end + let(:tests) { [test] } + let(:test) do + Proforma::Test.new( + id: 'test-id', + title: 'title', + description: 'description', + internal_description: 'regular_file', + test_type: 'test_type', + files: test_files, + meta_data: { + 'feedback-message' => 'feedback-message', + 'testing-framework' => 'testing-framework', + 'testing-framework-version' => 'testing-framework-version' + } + ) + end + let(:test_files) { [test_file] } + let(:test_file) do + Proforma::TaskFile.new( + id: 'test_file_id', + content: 'testfile-content', + filename: 'testfile.txt', + used_by_grader: 'yes', + visible: 'no', + usage_by_lms: 'display', + binary: false, + internal_description: 'teacher_defined_test' + ) + end + let(:model_solutions) { [model_solution] } + let(:model_solution) do + Proforma::ModelSolution.new( + id: 'ms-id', + files: ms_files + ) + end + let(:ms_files) { [ms_file] } + let(:ms_file) do + Proforma::TaskFile.new( + id: 'ms-file', + content: 'ms-content', + filename: 'filename.txt', + used_by_grader: 'used_by_grader', + visible: 'yes', + usage_by_lms: 'display', + binary: false, + internal_description: 'reference_implementation' + ) + end + + it 'assigns all values to given exercise' do + expect(convert_to_exercise_service).to have_attributes( + id: exercise.id, + files: have(3).items + .and(include(have_attributes(content: 'ms-content', role: 'reference_implementation'))) + .and(include(have_attributes(content: 'content', role: 'regular_file'))) + .and(include(have_attributes(content: 'testfile-content', role: 'teacher_defined_test'))) + ) + end + end + end + end +end diff --git a/spec/services/proforma_service/export_task_spec.rb b/spec/services/proforma_service/export_task_spec.rb new file mode 100644 index 00000000..157094de --- /dev/null +++ b/spec/services/proforma_service/export_task_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ProformaService::ExportTask do + describe '.new' do + subject(:export_task) { described_class.new(exercise: exercise) } + + let(:exercise) { FactoryBot.build(:dummy) } + + it 'assigns exercise' do + expect(export_task.instance_variable_get(:@exercise)).to be exercise + end + + context 'without exercise' do + subject(:export_task) { described_class.new } + + it 'assigns exercise' do + expect(export_task.instance_variable_get(:@exercise)).to be nil + end + end + end + + describe '#execute' do + subject(:export_task) { described_class.call(exercise: exercise) } + + let(:task) { Proforma::Task.new } + let(:exercise) { FactoryBot.build(:dummy) } + let(:exporter) { instance_double('Proforma::Exporter', perform: 'zip') } + + before do + allow(ProformaService::ConvertExerciseToTask).to receive(:call).with(exercise: exercise).and_return(task) + allow(Proforma::Exporter).to receive(:new).with(task).and_return(exporter) + end + + it do + export_task + expect(exporter).to have_received(:perform) + end + end +end diff --git a/spec/services/proforma_service/import_spec.rb b/spec/services/proforma_service/import_spec.rb new file mode 100644 index 00000000..118284f1 --- /dev/null +++ b/spec/services/proforma_service/import_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ProformaService::Import do + describe '.new' do + subject(:import_service) { described_class.new(zip: zip, user: user) } + + let(:zip) { Tempfile.new('proforma_test_zip_file') } + let(:user) { FactoryBot.build(:teacher) } + + it 'assigns zip' do + expect(import_service.instance_variable_get(:@zip)).to be zip + end + + it 'assigns user' do + expect(import_service.instance_variable_get(:@user)).to be user + end + end + + describe '#execute' do + subject(:import_service) { described_class.call(zip: zip_file, user: import_user) } + + let(:user) { FactoryBot.create(:teacher) } + let(:import_user) { user } + let(:zip_file) { Tempfile.new('proforma_test_zip_file') } + let(:exercise) do + FactoryBot.create(:dummy, + instructions: 'instruction', + execution_environment: execution_environment, + files: files + tests, + uuid: uuid, + user: user) + end + + let(:uuid) {} + let(:execution_environment) { FactoryBot.build(:java) } + let(:files) { [] } + let(:tests) { [] } + let(:exporter) { ProformaService::ExportTask.call(exercise: exercise.reload).string } + + before do + zip_file.write(exporter) + zip_file.rewind + end + + it { is_expected.to be_an_equal_exercise_as exercise } + + it 'sets the correct user as owner of the exercise' do + expect(import_service.user).to be user + end + + it 'sets the uuid' do + expect(import_service.uuid).not_to be_blank + end + + context 'when no exercise exists' do + before { exercise.destroy } + + it { is_expected.to be_valid } + + it 'sets the correct user as owner of the exercise' do + expect(import_service.user).to be user + end + + it 'sets the uuid' do + expect(import_service.uuid).not_to be_blank + end + + context 'when task has a uuid' do + let(:uuid) { SecureRandom.uuid } + + it 'sets the uuid' do + expect(import_service.uuid).to eql uuid + end + end + end + + context 'when exercise has a mainfile' do + let(:files) { [file] } + let(:file) { FactoryBot.build(:file) } + + it { is_expected.to be_an_equal_exercise_as exercise } + + context 'when the mainfile is very large' do + let(:file) { FactoryBot.build(:file, content: 'test' * 10**5) } + + it { is_expected.to be_an_equal_exercise_as exercise } + end + end + + context 'when exercise has a regular file' do + let(:files) { [file] } + let(:file) { FactoryBot.build(:file, role: 'regular_file') } + + it { is_expected.to be_an_equal_exercise_as exercise } + + context 'when file has an attachment' do + let(:file) { FactoryBot.build(:file, :image, role: 'regular_file') } + + it { is_expected.to be_an_equal_exercise_as exercise } + end + end + + context 'when exercise has a file with role reference implementation' do + let(:files) { [file] } + let(:file) { FactoryBot.build(:file, role: 'reference_implementation', read_only: true) } + + it { is_expected.to be_an_equal_exercise_as exercise } + end + + context 'when exercise has multiple files with role reference implementation' do + let(:files) { FactoryBot.build_list(:file, 2, role: 'reference_implementation', read_only: true) } + + it { is_expected.to be_an_equal_exercise_as exercise } + end + + context 'when exercise has a test' do + let(:tests) { [test] } + let(:test) { FactoryBot.build(:test_file) } + + it { is_expected.to be_an_equal_exercise_as exercise } + end + + context 'when exercise has multiple tests' do + let(:tests) { FactoryBot.build_list(:test_file, 2) } + + it { is_expected.to be_an_equal_exercise_as exercise } + end + + # context 'when zip contains multiple tasks' do + # let(:exporter) { ProformaService::ExportTasks.call(exercises: [exercise, exercise2]).string } + + # let(:exercise2) do + # FactoryBot.create(:dummy, + # instruction: 'instruction2', + # execution_environment: execution_environment, + # exercise_files: [], + # tests: [], + # user: user) + # end + + # it 'imports the exercises from zip containing multiple zips' do + # expect(import_service).to all be_an(Exercise) + # end + + # it 'imports the zip exactly how they were exported' do + # expect(import_service).to all be_an_equal_exercise_as(exercise).or be_an_equal_exercise_as(exercise2) + # end + + # context 'when a exercise has files and tests' do + # let(:files) { [FactoryBot.build(:file), FactoryBot.build(:file, role: 'regular_file')] } + # let(:tests) { FactoryBot.build_list(:codeharbor_test, 2) } + + # it 'imports the zip exactly how the were exported' do + # expect(import_service).to all be_an_equal_exercise_as(exercise).or be_an_equal_exercise_as(exercise2) + # end + # end + # end + + context 'when task in zip has a different uuid' do + let(:uuid) { SecureRandom.uuid } + let(:new_uuid) { SecureRandom.uuid } + + before do + exercise.update(uuid: new_uuid) + end + + it 'creates a new Exercise' do + expect(import_service.id).not_to be exercise.id + end + end + + context 'when task in zip has the same uuid and nothing has changed' do + let(:uuid) { SecureRandom.uuid } + + it 'updates the old Exercise' do + expect(import_service.id).to be exercise.id + end + + context 'when another user imports the exercise' do + let(:import_user) { FactoryBot.create(:teacher) } + + it 'creates a new Exercise' do + expect(import_service.id).not_to be exercise.id + end + end + end + end +end diff --git a/spec/support/expectations/equal_exercise.rb b/spec/support/expectations/equal_exercise.rb new file mode 100644 index 00000000..df21ec3d --- /dev/null +++ b/spec/support/expectations/equal_exercise.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rspec/expectations' + +RSpec::Matchers.define :be_an_equal_exercise_as do |exercise| + match do |actual| + equal?(actual, exercise) + end + failure_message do |actual| + "#{actual.inspect} is not equal to \n#{exercise.inspect}. \nLast checked attribute: #{@last_checked}" + end + + def equal?(object, other) + return false unless object.class == other.class + return attributes_equal?(object, other) if object.is_a?(ApplicationRecord) + return array_equal?(object, other) if object.is_a?(Array) || object.is_a?(ActiveRecord::Associations::CollectionProxy) + + object == other + end + + def attributes_equal?(object, other) + other_attributes = attributes_and_associations(other) + attributes_and_associations(object).each do |k, v| + @last_checked = "#{k}: \n\"#{v}\" vs \n\"#{other_attributes[k]}\"" + return false unless equal?(other_attributes[k], v) + end + true + end + + def array_equal?(object, other) + return true if object == other # for [] + return false if object.length != other.length + + object.to_a.product(other.to_a).map { |k, v| equal?(k, v) }.any? + end + + def attributes_and_associations(object) + object.attributes.dup.tap do |attributes| + attributes[:files] = object.files if defined? object.files + end.except('id', 'created_at', 'updated_at', 'exercise_id', 'uuid', 'import_checksum') + end +end