diff --git a/app/graphql/mutations/clone_samples.rb b/app/graphql/mutations/clone_samples.rb new file mode 100644 index 0000000000..961db2adc1 --- /dev/null +++ b/app/graphql/mutations/clone_samples.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Mutations + # Base Mutation + class CloneSamples < BaseMutation + null true + description 'Copy samples to another project.' + argument :new_project_id, ID, + required: false, + description: 'The Node ID of the project to copy to. For example, `gid://irida/Project/a84cd757-dedb-4c64-8b01-097020163077`.' # rubocop:disable Layout/LineLength + argument :new_project_puid, ID, + required: false, + description: 'Persistent Unique Identifier of the project to copy to. For example, `INXT_PRJ_AAAAAAAAAA`.' + argument :project_id, ID, # rubocop:disable GraphQL/ExtractInputType + required: false, + description: 'The Node ID of the project to copy to. For example, `gid://irida/Project/a84cd757-dedb-4c64-8b01-097020163077`.' # rubocop:disable Layout/LineLength + argument :project_puid, ID, # rubocop:disable GraphQL/ExtractInputType + required: false, + description: 'Persistent Unique Identifier of the project to copy to. For example, `INXT_PRJ_AAAAAAAAAA`.' + + argument :sample_ids, [ID], required: true, description: 'List of samples to copy.' # rubocop:disable GraphQL/ExtractInputType + validates required: { one_of: %i[new_project_id new_project_puid] } + validates required: { one_of: %i[project_id project_puid] } + + field :errors, [Types::UserErrorType], null: false, description: 'A list of errors that prevented the mutation.' + field :samples, GraphQL::Types::JSON, description: 'List of original and copied sample ids.' + + def resolve(args) # rubocop:disable Metrics/MethodLength + project = get_project_from_id_or_puid_args(args) + + if project.nil? || !project.persisted? + user_errors = [{ + path: ['project'], + message: 'Project not found by provided ID or PUID' + }] + return { + samples: nil, + errors: user_errors + } + end + + new_project_args = { project_id: args[:new_project_id], project_puid: args[:new_project_puid] } + new_project = get_project_from_id_or_puid_args(new_project_args) + + if new_project.nil? || !new_project.persisted? + user_errors = [{ + path: ['new_project'], + message: 'Project not found by provided ID or PUID' + }] + return { + samples: nil, + errors: user_errors + } + end + + clone_samples(project, new_project.id, args[:sample_ids]) + end + + def ready?(**_args) + authorize!(to: :mutate?, with: GraphqlPolicy, context: { user: context[:current_user], token: context[:token] }) + end + + private + + def clone_samples(project, new_project_id, sample_gids) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize + user_errors = [] + # remove prefix from sample_ids + sample_ids = sample_gids.map do |sample_gid| + IridaSchema.parse_gid(sample_gid, { expected_type: Sample }).model_id + rescue GraphQL::CoercionError => e + user_errors.append( + { + path: ['copySamples'], + message: e.message + } + ) + next + end + + samples = Samples::CloneService.new( + project, current_user + ).execute(new_project_id, sample_ids.compact) + + prepended_samples = [] + unless samples.empty? + # add the prefix to sample_ids + prepended_samples = samples.map do |key, value| + { original: URI::GID.build(app: GlobalID.app, model_name: Sample.name, model_id: key).to_s, + copy: URI::GID.build(app: GlobalID.app, model_name: Sample.name, model_id: value).to_s } + end + end + + project_user_errors = [] + if project.errors.count.positive? + project_user_errors = project.errors.map do |error| + { + path: ['samples', error.attribute.to_s.camelize(:lower)], + message: error.message + } + end + end + + { + samples: prepended_samples, + errors: user_errors.concat(project_user_errors) + } + end + end +end diff --git a/app/graphql/mutations/transfer_samples.rb b/app/graphql/mutations/transfer_samples.rb new file mode 100644 index 0000000000..26f4d3d1ed --- /dev/null +++ b/app/graphql/mutations/transfer_samples.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Mutations + # Base Mutation + class TransferSamples < BaseMutation + null true + description 'Transfer a list of sample to another project.' + argument :new_project_id, ID, + required: false, + description: 'The Node ID of the project to transfer to. For example, `gid://irida/Project/a84cd757-dedb-4c64-8b01-097020163077`.' # rubocop:disable Layout/LineLength + argument :new_project_puid, ID, + required: false, + description: 'Persistent Unique Identifier of the project to transfer to. For example, `INXT_PRJ_AAAAAAAAAA`.' # rubocop:disable Layout/LineLength + argument :project_id, ID, # rubocop:disable GraphQL/ExtractInputType + required: false, + description: 'The Node ID of the project to transfer to. For example, `gid://irida/Project/a84cd757-dedb-4c64-8b01-097020163077`.' # rubocop:disable Layout/LineLength + argument :project_puid, ID, # rubocop:disable GraphQL/ExtractInputType + required: false, + description: 'Persistent Unique Identifier of the project to transfer to. For example, `INXT_PRJ_AAAAAAAAAA`.' # rubocop:disable Layout/LineLength + + argument :sample_ids, [ID], required: true, description: 'List of samples to transfer.' # rubocop:disable GraphQL/ExtractInputType + validates required: { one_of: %i[new_project_id new_project_puid] } + validates required: { one_of: %i[project_id project_puid] } + + field :errors, [Types::UserErrorType], null: false, description: 'A list of errors that prevented the mutation.' + field :samples, [ID], description: 'List of transfered sample ids.' + + def resolve(args) # rubocop:disable Metrics/MethodLength + project = get_project_from_id_or_puid_args(args) + + if project.nil? || !project.persisted? + user_errors = [{ + path: ['project'], + message: 'Project not found by provided ID or PUID' + }] + return { + samples: nil, + errors: user_errors + } + end + + new_project_args = { project_id: args[:new_project_id], project_puid: args[:new_project_puid] } + new_project = get_project_from_id_or_puid_args(new_project_args) + + if new_project.nil? || !new_project.persisted? + user_errors = [{ + path: ['new_project'], + message: 'Project not found by provided ID or PUID' + }] + return { + samples: nil, + errors: user_errors + } + end + + transfer_samples(project, new_project.id, args[:sample_ids]) + end + + def ready?(**_args) + authorize!(to: :mutate?, with: GraphqlPolicy, context: { user: context[:current_user], token: context[:token] }) + end + + private + + def transfer_samples(project, new_project_id, sample_gids) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize + user_errors = [] + # remove prefix from sample_ids + sample_ids = sample_gids.map do |sample_gid| + IridaSchema.parse_gid(sample_gid, { expected_type: Sample }).model_id + rescue GraphQL::CoercionError => e + user_errors.append( + { + path: ['transferSamples'], + message: e.message + } + ) + next + end + + samples = Samples::TransferService.new( + project, current_user + ).execute(new_project_id, sample_ids.compact) + + if samples.empty? # rubocop:disable Style/ConditionalAssignment + samples = nil + else + # add the prefix to sample_ids + samples = samples.map do |sample_id| + URI::GID.build(app: GlobalID.app, model_name: Sample.name, model_id: sample_id).to_s + end + end + + project_user_errors = [] + if project.errors.count.positive? + project_user_errors = project.errors.map do |error| + { + path: ['samples', error.attribute.to_s.camelize(:lower)], + message: error.message + } + end + end + + { + samples:, + errors: user_errors.concat(project_user_errors) + } + end + end +end diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index a1cc78257a..f101709009 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -578,6 +578,61 @@ enum AttachmentOrderField { updated_at } +""" +Autogenerated input type of CloneSamples +""" +input CloneSamplesInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The Node ID of the project to copy to. For example, `gid://irida/Project/a84cd757-dedb-4c64-8b01-097020163077`. + """ + newProjectId: ID + + """ + Persistent Unique Identifier of the project to copy to. For example, `INXT_PRJ_AAAAAAAAAA`. + """ + newProjectPuid: ID + + """ + The Node ID of the project to copy to. For example, `gid://irida/Project/a84cd757-dedb-4c64-8b01-097020163077`. + """ + projectId: ID + + """ + Persistent Unique Identifier of the project to copy to. For example, `INXT_PRJ_AAAAAAAAAA`. + """ + projectPuid: ID + + """ + List of samples to copy. + """ + sampleIds: [ID!]! +} + +""" +Autogenerated return type of CloneSamples. +""" +type CloneSamplesPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + A list of errors that prevented the mutation. + """ + errors: [UserError!]! + + """ + List of original and copied sample ids. + """ + samples: JSON +} + """ Autogenerated input type of CreateDirectUpload """ @@ -1311,6 +1366,16 @@ type Mutation { input: AttachFilesToSampleInput! ): AttachFilesToSamplePayload + """ + Copy samples to another project. + """ + copySamples( + """ + Parameters for CloneSamples + """ + input: CloneSamplesInput! + ): CloneSamplesPayload + """ Create blob to upload data to. """ @@ -1351,6 +1416,16 @@ type Mutation { input: CreateSampleInput! ): CreateSamplePayload + """ + Transfer a list of sample to another project. + """ + transferSamples( + """ + Parameters for TransferSamples + """ + input: TransferSamplesInput! + ): TransferSamplesPayload + """ Update metadata for a sample. """ @@ -2630,6 +2705,61 @@ enum SampleOrderField { updated_at } +""" +Autogenerated input type of TransferSamples +""" +input TransferSamplesInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The Node ID of the project to transfer to. For example, `gid://irida/Project/a84cd757-dedb-4c64-8b01-097020163077`. + """ + newProjectId: ID + + """ + Persistent Unique Identifier of the project to transfer to. For example, `INXT_PRJ_AAAAAAAAAA`. + """ + newProjectPuid: ID + + """ + The Node ID of the project to transfer to. For example, `gid://irida/Project/a84cd757-dedb-4c64-8b01-097020163077`. + """ + projectId: ID + + """ + Persistent Unique Identifier of the project to transfer to. For example, `INXT_PRJ_AAAAAAAAAA`. + """ + projectPuid: ID + + """ + List of samples to transfer. + """ + sampleIds: [ID!]! +} + +""" +Autogenerated return type of TransferSamples. +""" +type TransferSamplesPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + A list of errors that prevented the mutation. + """ + errors: [UserError!]! + + """ + List of transfered sample ids. + """ + samples: [ID!] +} + """ Autogenerated input type of UpdateSampleMetadata """ diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index d248ea3ed1..ba119d5073 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -8,10 +8,12 @@ class MutationType < Types::BaseObject field :attach_files_to_group, mutation: Mutations::AttachFilesToGroup # rubocop:disable GraphQL/FieldDescription field :attach_files_to_project, mutation: Mutations::AttachFilesToProject # rubocop:disable GraphQL/FieldDescription field :attach_files_to_sample, mutation: Mutations::AttachFilesToSample # rubocop:disable GraphQL/FieldDescription, GraphQL/ExtractType + field :copy_samples, mutation: Mutations::CloneSamples # rubocop:disable GraphQL/FieldDescription field :create_direct_upload, mutation: Mutations::CreateDirectUpload # rubocop:disable GraphQL/FieldDescription field :create_group, mutation: Mutations::CreateGroup # rubocop:disable GraphQL/FieldDescription field :create_project, mutation: Mutations::CreateProject # rubocop:disable GraphQL/FieldDescription field :create_sample, mutation: Mutations::CreateSample # rubocop:disable GraphQL/FieldDescription,GraphQL/ExtractType + field :transfer_samples, mutation: Mutations::TransferSamples # rubocop:disable GraphQL/FieldDescription field :update_sample_metadata, mutation: Mutations::UpdateSampleMetadata # rubocop:disable GraphQL/FieldDescription end end diff --git a/app/services/samples/clone_service.rb b/app/services/samples/clone_service.rb index 33ad78df54..02f5fcbf98 100644 --- a/app/services/samples/clone_service.rb +++ b/app/services/samples/clone_service.rb @@ -31,15 +31,25 @@ def validate(new_project_id, sample_ids) raise CloneError, I18n.t('services.samples.clone.same_project') end - def clone_samples(sample_ids) # rubocop:disable Metrics/AbcSize + def clone_samples(sample_ids) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity cloned_sample_ids = {} cloned_sample_puids = {} + not_found_sample_ids = [] sample_ids.each do |sample_id| sample = Sample.find_by(id: sample_id, project_id: @project.id) cloned_sample = clone_sample(sample) cloned_sample_ids[sample_id] = cloned_sample.id unless cloned_sample.nil? cloned_sample_puids[sample.puid] = cloned_sample.puid unless cloned_sample.nil? + rescue StandardError + not_found_sample_ids << sample_id unless sample + next + end + + unless not_found_sample_ids.empty? + @project.errors.add(:samples, + I18n.t('services.samples.clone.samples_not_found', + sample_ids: not_found_sample_ids.join(', '))) end if cloned_sample_ids.count.positive? diff --git a/config/locales/en.yml b/config/locales/en.yml index 53a208cd56..ccbb5e90b4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1587,6 +1587,7 @@ en: empty_sample_ids: The sample ids are empty. same_project: The source and destination projects are the same. Please select a different destination project. sample_exists: 'Sample %{sample_puid}: Conflicts with sample named ''%{sample_name}'' in the target project' + samples_not_found: 'Samples with the following sample ids could not be copied as they were not found in the source project: %{sample_ids}' metadata: empty_metadata: No metadata was received to update sample '%{sample_name}' fields: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 32a497b414..85fc4fa9dc 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1587,6 +1587,7 @@ fr: empty_sample_ids: The sample ids are empty. same_project: The source and destination projects are the same. Please select a different destination project. sample_exists: 'Sample %{sample_puid}: Conflicts with sample named ''%{sample_name}'' in the target project' + samples_not_found: 'Samples with the following sample ids could not be copied as they were not found in the source project: %{sample_ids}' metadata: empty_metadata: No metadata was received to update sample '%{sample_name}' fields: diff --git a/test/graphql/clone_samples_mutation_test.rb b/test/graphql/clone_samples_mutation_test.rb new file mode 100644 index 0000000000..adb43c9ddc --- /dev/null +++ b/test/graphql/clone_samples_mutation_test.rb @@ -0,0 +1,560 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CloneSamplesMutationTest < ActiveSupport::TestCase + CLONE_SAMPLE_USING_PROJECT_ID_MUTATION = <<~GRAPHQL + mutation($projectId: ID!, $newProjectId: ID!, $sampleIds: [ID!]!) { + copySamples(input: { projectId: $projectId, newProjectId: $newProjectId, sampleIds: $sampleIds }) { + errors { + path + message + } + samples + } + } + GRAPHQL + + CLONE_SAMPLE_USING_PROJECT_PUID_MUTATION = <<~GRAPHQL + mutation($projectPuid: ID!, $newProjectPuid: ID!, $sampleIds: [ID!]!) { + copySamples(input: { projectPuid: $projectPuid, newProjectPuid: $newProjectPuid, sampleIds: $sampleIds }) { + errors { + path + message + } + samples + } + } + GRAPHQL + + def setup + @user = users(:john_doe) + @api_scope_token = personal_access_tokens(:john_doe_valid_pat) + @read_api_scope_token = personal_access_tokens(:john_doe_valid_read_pat) + end + + test 'copySamples mutation should work with valid params, project global ids, and api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_empty data['errors'] + assert_not_empty data['samples'] + + data['samples'].each do |original_copy_pair| + original_id = original_copy_pair[:original] + original_sample = IridaSchema.object_from_id(original_id, { expected_type: Sample }) + copy_id = original_copy_pair[:copy] + copy_sample = IridaSchema.object_from_id(copy_id, { expected_type: Sample }) + + assert_equal project1.id, original_sample.project.id + assert_equal project2.id, copy_sample.project.id + assert_equal original_sample.name, copy_sample.name + assert_equal original_sample.description, copy_sample.description + end + end + + test 'copySamples mutation should work with valid params, puids, and api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectPuid: project1.puid, + newProjectPuid: project2.puid, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_empty data['errors'] + assert_not_empty data['samples'] + + data['samples'].each do |original_copy_pair| + original_id = original_copy_pair[:original] + original_sample = IridaSchema.object_from_id(original_id, { expected_type: Sample }) + copy_id = original_copy_pair[:copy] + copy_sample = IridaSchema.object_from_id(copy_id, { expected_type: Sample }) + + assert_equal project1.id, original_sample.project.id + assert_equal project2.id, copy_sample.project.id + assert_equal original_sample.name, copy_sample.name + assert_equal original_sample.description, copy_sample.description + end + end + + test 'copySamples mutation should not work with valid params, puids, and api scope token with uploader access level' do # rubocop:disable Layout/LineLength + user = users(:user_bot_account0) + token = personal_access_tokens(:user_bot_account0_valid_pat) + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: user, token: }, + variables: { projectPuid: project1.puid, + newProjectPuid: project2.puid, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['errors'], 'should have errors.' + + assert_not_empty result['errors'] + assert_equal 'You are not authorized to copy samples from project Project 1 on this server.', + result['errors'][0]['message'] + assert_equal ['copySamples'], result['errors'][0]['path'] + end + + test 'copySamples mutation should not work with invalid params and api scope token' do + project1 = projects(:project1) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectPuid: project1.puid, + newProjectPuid: project1.puid, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_not_empty data['errors'] + assert_empty data['samples'], 'sample should not be populated as one was not created.' + + assert_equal %w[samples base], data['errors'][0]['path'] + assert_equal 'The source and destination projects are the same. Please select a different destination project.', + data['errors'][0]['message'] + end + + test 'copySamples mutation should not work with valid params and read api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @read_api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['errors'], 'shouldn\'t work and have errors.' + + error_message = result['errors'][0]['message'] + + assert_equal 'You are not authorized to perform this action', error_message + end + + test 'copySamples mutation should not work with valid params due to expired token for uploader access level' do + user = users(:user_bot_account0) + token = personal_access_tokens(:user_bot_account0_expired_pat) + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: user, token: }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['errors'], 'shouldn\'t work and have errors.' + + error_message = result['errors'][0]['message'] + + assert_equal 'You are not authorized to perform this action', error_message + end + + test 'copySamples mutation should not work with unauthorized project and valid api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: users(:jane_doe), + token: personal_access_tokens(:jane_doe_valid_pat) }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project2.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['errors'], 'shouldn\'t work and have errors.' + + error_message = result['errors'][0]['message'] + + assert_equal I18n.t(:'action_policy.policy.project.clone_sample?', name: project1.name), error_message + end + + test 'copySamples mutation should not work with invalid original project puid and valid api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectPuid: 'INVALID_PUID', + newProjectPuid: project2.puid, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['data']['copySamples']['errors'], 'shouldn\'t work and have errors.' + + errors = result['data']['copySamples']['errors'] + + assert_equal 'Project not found by provided ID or PUID', errors[0]['message'] + end + + test 'copySamples mutation should not work with invalid target project puid and valid api scope token' do + project1 = projects(:project1) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectPuid: project1.puid, + newProjectPuid: 'INVALID_PUID', + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['data']['copySamples']['errors'], 'shouldn\'t work and have errors.' + + errors = result['data']['copySamples']['errors'] + + assert_equal 'Project not found by provided ID or PUID', errors[0]['message'] + end + + test 'copySamples mutation should not work with invalid original project id and valid api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: 'gid://irida/Project/not-a-valid-uuid', + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + expected_error = { 'message' => 'Project not found by provided ID or PUID', 'path' => ['project'] } + + assert_equal expected_error, result['data']['copySamples']['errors'][0] + end + + test 'copySamples mutation should not work with invalid target project id and valid api scope token' do + project1 = projects(:project1) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: 'gid://irida/Project/not-a-valid-uuid', + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + expected_error = { 'message' => 'Project not found by provided ID or PUID', 'path' => ['new_project'] } + + assert_equal expected_error, result['data']['copySamples']['errors'][0] + end + + test 'copySamples mutation should not work with incorrectly formatted project id and valid api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: 'project_ids_dont_look_like_this', + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + expected_error = { 'message' => 'project_ids_dont_look_like_this is not a valid IRIDA Next ID.', + 'locations' => [{ 'line' => 2, 'column' => 3 }], 'path' => ['copySamples'] } + + assert_equal expected_error, result['errors'][0] + end + + test 'copySamples mutation should fail at copying samples onto its own project' do + project1 = projects(:project1) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project1.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_not_empty data['errors'] + assert_equal 'The source and destination projects are the same. Please select a different destination project.', + data['errors'][0]['message'] + end + + test 'copySamples mutation should not work when sample is not on original project' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project2.to_global_id.to_s, + newProjectId: project1.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_not_empty data['errors'] + assert data['errors'][0]['message'].include?('Samples with the following sample ids could not be copied as they were not found in the source project:') # rubocop:disable Layout/LineLength + end + + test 'copySamples mutation should not work with no samples given' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_not_empty data['errors'] + assert_equal 'The sample ids are empty.', data['errors'][0]['message'] + end + + test 'copySamples mutation should not work when copying a sample back from where it was copied from' do + project1 = projects(:project1) + project2 = projects(:project2) + + result1 = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result1['errors'], 'should work and have no errors.' + data1 = result1['data']['copySamples'] + s1 = data1['samples'][0][:copy] + sample1 = IridaSchema.object_from_id(s1, { expected_type: Sample }) + + # now copy it back to p1 + result2 = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project2.to_global_id.to_s, + newProjectId: project1.to_global_id.to_s, + sampleIds: [ + s1 + ] }) + + assert_nil result2['errors'], 'should work and have no errors.' + data2 = result2['data']['copySamples'] + + assert_not_empty data2, 'copySample should be populated when no authorization errors' + assert_not_empty data2['errors'] + + assert_equal I18n.t(:'services.samples.clone.sample_exists', + sample_name: sample1.name, + sample_puid: sample1.puid), + data2['errors'][0]['message'] + end + + test 'copySamples mutation should partially work with one sample being invalid' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project2.samples[0].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_not_empty data['samples'] + assert_equal 1, data['samples'].count + + original_id = data['samples'][0][:original] + original_sample = IridaSchema.object_from_id(original_id, { expected_type: Sample }) + copy_id = data['samples'][0][:copy] + copy_sample = IridaSchema.object_from_id(copy_id, { expected_type: Sample }) + + assert_equal project1.id, original_sample.project.id + assert_equal project2.id, copy_sample.project.id + assert_equal original_sample.name, copy_sample.name + assert_equal original_sample.description, copy_sample.description + + # check that the failed sample is in the error, and matches the one we expect to fail + assert_not_empty data['errors'] + assert_equal 1, data['errors'].count + assert data['errors'][0]['message'].include?('Samples with the following sample ids could not be copied as they were not found in the source project:') # rubocop:disable Layout/LineLength + expected_failed_sample = project2.samples[0].id + assert data['errors'][0]['message'].include?(expected_failed_sample) + end + + test 'copySamples mutation should not work when sample gid is invalid, partial success' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + 'not a valid sample gid' + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_not_empty data['samples'] + assert_equal 1, data['samples'].count + + original_id = data['samples'][0][:original] + original_sample = IridaSchema.object_from_id(original_id, { expected_type: Sample }) + copy_id = data['samples'][0][:copy] + copy_sample = IridaSchema.object_from_id(copy_id, { expected_type: Sample }) + + assert_equal project1.id, original_sample.project.id + assert_equal project2.id, copy_sample.project.id + assert_equal original_sample.name, copy_sample.name + assert_equal original_sample.description, copy_sample.description + + assert_not_empty data['errors'] + assert_equal 1, data['errors'].count + assert_equal 'not a valid sample gid is not a valid IRIDA Next ID.', data['errors'][0]['message'] + assert_equal ['copySamples'], data['errors'][0]['path'] + end + + test 'copySamples mutation should not work when sample gid is actually a group gid, partial success' do + project1 = projects(:project1) + project2 = projects(:project2) + group1 = groups(:group_one) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + group1.to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_not_empty data['samples'] + assert_equal 1, data['samples'].count + + original_id = data['samples'][0][:original] + original_sample = IridaSchema.object_from_id(original_id, { expected_type: Sample }) + copy_id = data['samples'][0][:copy] + copy_sample = IridaSchema.object_from_id(copy_id, { expected_type: Sample }) + + assert_equal project1.id, original_sample.project.id + assert_equal project2.id, copy_sample.project.id + assert_equal original_sample.name, copy_sample.name + assert_equal original_sample.description, copy_sample.description + + assert_not_empty data['errors'] + assert_equal 1, data['errors'].count + assert_equal "#{group1.to_global_id} is not a valid ID for Sample", data['errors'][0]['message'] + assert_equal ['copySamples'], data['errors'][0]['path'] + end + + test 'copySamples mutation should not work when sample gid is actually a project gid, partial success' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(CLONE_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['copySamples'] + + assert_not_empty data, 'copySample should be populated when no authorization errors' + assert_not_empty data['samples'] + assert_equal 1, data['samples'].count + + original_id = data['samples'][0][:original] + original_sample = IridaSchema.object_from_id(original_id, { expected_type: Sample }) + copy_id = data['samples'][0][:copy] + copy_sample = IridaSchema.object_from_id(copy_id, { expected_type: Sample }) + + assert_equal project1.id, original_sample.project.id + assert_equal project2.id, copy_sample.project.id + assert_equal original_sample.name, copy_sample.name + assert_equal original_sample.description, copy_sample.description + + assert_not_empty data['errors'] + assert_equal 1, data['errors'].count + assert_equal "#{project1.to_global_id} is not a valid ID for Sample", data['errors'][0]['message'] + assert_equal ['copySamples'], data['errors'][0]['path'] + end +end diff --git a/test/graphql/transfer_samples_mutation_test.rb b/test/graphql/transfer_samples_mutation_test.rb new file mode 100644 index 0000000000..0508233f67 --- /dev/null +++ b/test/graphql/transfer_samples_mutation_test.rb @@ -0,0 +1,574 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TransferSamplesMutationTest < ActiveSupport::TestCase + TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION = <<~GRAPHQL + mutation($projectId: ID!, $newProjectId: ID!, $sampleIds: [ID!]!) { + transferSamples(input: { projectId: $projectId, newProjectId: $newProjectId, sampleIds: $sampleIds }) { + errors { + path + message + } + samples + } + } + GRAPHQL + + TRANSFER_SAMPLE_USING_PROJECT_PUID_MUTATION = <<~GRAPHQL + mutation($projectPuid: ID!, $newProjectPuid: ID!, $sampleIds: [ID!]!) { + transferSamples(input: { projectPuid: $projectPuid, newProjectPuid: $newProjectPuid, sampleIds: $sampleIds }) { + errors { + path + message + } + samples + } + } + GRAPHQL + + def setup + @user = users(:john_doe) + @api_scope_token = personal_access_tokens(:john_doe_valid_pat) + @read_api_scope_token = personal_access_tokens(:john_doe_valid_read_pat) + end + + test 'transferSamples mutation should work with valid params, project global ids, and api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + p1_sample_count = project1.samples.count + p2_sample_count = project2.samples.count + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_empty data['errors'] + assert_not_empty data['samples'] + + data['samples'].each do |sample_id| + sample = IridaSchema.object_from_id(sample_id, { expected_type: Sample }) + assert_equal project2.id, sample.project.id + end + + assert_equal p1_sample_count - 2, project1.samples.count + assert_equal p2_sample_count + 2, project2.samples.count + end + + test 'transferSamples mutation should work with valid params, puids, and api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + p1_sample_count = project1.samples.count + p2_sample_count = project2.samples.count + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectPuid: project1.puid, + newProjectPuid: project2.puid, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_empty data['errors'] + assert_not_empty data['samples'] + + data['samples'].each do |sample_id| + sample = IridaSchema.object_from_id(sample_id, { expected_type: Sample }) + assert_equal project2.id, sample.project.id + end + + assert_equal p1_sample_count - 2, project1.samples.count + assert_equal p2_sample_count + 2, project2.samples.count + end + + test 'transferSamples mutation should not work with valid params, puids, and api scope token with uploader access level' do # rubocop:disable Layout/LineLength + user = users(:user_bot_account0) + token = personal_access_tokens(:user_bot_account0_valid_pat) + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: user, token: }, + variables: { projectPuid: project1.puid, + newProjectPuid: project2.puid, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['errors'], 'should have errors.' + + assert_not_empty result['errors'] + assert_equal 'You are not authorized to transfer samples for project Project 1 on this server.', + result['errors'][0]['message'] + assert_equal ['transferSamples'], result['errors'][0]['path'] + end + + test 'transferSamples mutation should not work with invalid params and api scope token' do + project1 = projects(:project1) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectPuid: project1.puid, + newProjectPuid: project1.puid, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSamples should be populated when no authorization errors' + assert_not_empty data['errors'] + assert_nil data['samples'], 'sample should not be populated as one was not created.' + + assert_equal %w[samples base], data['errors'][0]['path'] + assert_equal 'The samples already exist in the project. Please select a different project.', + data['errors'][0]['message'] + end + + test 'transferSamples mutation should not work with valid params and read api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @read_api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['errors'], 'shouldn\'t work and have errors.' + + error_message = result['errors'][0]['message'] + + assert_equal 'You are not authorized to perform this action', error_message + end + + test 'transferSamples mutation should not work with valid params due to expired token for uploader access level' do + user = users(:user_bot_account0) + token = personal_access_tokens(:user_bot_account0_expired_pat) + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: user, token: }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['errors'], 'shouldn\'t work and have errors.' + + error_message = result['errors'][0]['message'] + + assert_equal 'You are not authorized to perform this action', error_message + end + + test 'transferSamples mutation should not work with unauthorized project and valid api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: users(:jane_doe), + token: personal_access_tokens(:jane_doe_valid_pat) }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project2.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['errors'], 'shouldn\'t work and have errors.' + + error_message = result['errors'][0]['message'] + + assert_equal I18n.t(:'action_policy.policy.project.transfer_sample?', name: project1.name), error_message + end + + test 'transferSamples mutation should not work with invalid original project puid and valid api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectPuid: 'INVALID_PUID', + newProjectPuid: project2.puid, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['data']['transferSamples']['errors'], 'shouldn\'t work and have errors.' + + errors = result['data']['transferSamples']['errors'] + + assert_equal 'Project not found by provided ID or PUID', errors[0]['message'] + end + + test 'transferSamples mutation should not work with invalid target project puid and valid api scope token' do + project1 = projects(:project1) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_PUID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectPuid: project1.puid, + newProjectPuid: 'INVALID_PUID', + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_not_nil result['data']['transferSamples']['errors'], 'shouldn\'t work and have errors.' + + errors = result['data']['transferSamples']['errors'] + + assert_equal 'Project not found by provided ID or PUID', errors[0]['message'] + end + + test 'transferSamples mutation should not work with invalid original project id and valid api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: 'gid://irida/Project/not-a-valid-uuid', + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + expected_error = { 'message' => 'Project not found by provided ID or PUID', 'path' => ['project'] } + + assert_equal expected_error, result['data']['transferSamples']['errors'][0] + end + + test 'transferSamples mutation should not work with invalid target project id and valid api scope token' do + project1 = projects(:project1) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: 'gid://irida/Project/not-a-valid-uuid', + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + expected_error = { 'message' => 'Project not found by provided ID or PUID', 'path' => ['new_project'] } + + assert_equal expected_error, result['data']['transferSamples']['errors'][0] + end + + test 'transferSamples mutation should not work with incorrectly formatted project id and valid api scope token' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: 'project_ids_dont_look_like_this', + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + expected_error = { 'message' => 'project_ids_dont_look_like_this is not a valid IRIDA Next ID.', + 'locations' => [{ 'line' => 2, 'column' => 3 }], 'path' => ['transferSamples'] } + + assert_equal expected_error, result['errors'][0] + end + + test 'transferSamples mutation should fail when transfering to self' do + project1 = projects(:project1) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project1.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_not_empty data['errors'] + assert_equal 'The samples already exist in the project. Please select a different project.', + data['errors'][0]['message'] + end + + test 'transferSamples mutation should fail when sample is not on original project' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project2.to_global_id.to_s, + newProjectId: project1.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.samples[1].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_not_empty data['errors'] + assert data['errors'][0]['message'].include?('Samples with the following sample ids could not be transferred as they were not found in the source project:') # rubocop:disable Layout/LineLength + end + + test 'transferSamples mutation should fail when no samples given' do + project1 = projects(:project1) + project2 = projects(:project2) + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_not_empty data['errors'] + assert_equal 'The sample ids are empty.', data['errors'][0]['message'] + end + + test 'transferSamples mutation should work when transfering a sample back and forth' do + project1 = projects(:project1) + project2 = projects(:project2) + + p1_sample_count = project1.samples.count + p2_sample_count = project2.samples.count + + result1 = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s + ] }) + + assert_nil result1['errors'], 'should work and have no errors.' + + data1 = result1['data']['transferSamples'] + + assert_not_empty data1, 'transferSample should be populated when no authorization errors' + assert_empty data1['errors'] + assert_not_empty data1['samples'] + + sample1 = IridaSchema.object_from_id(data1['samples'][0], { expected_type: Sample }) + assert_equal project2.id, sample1.project.id + + assert_equal p1_sample_count - 1, project1.samples.count + assert_equal p2_sample_count + 1, project2.samples.count + + # Now transfer the sample back + result2 = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project2.to_global_id.to_s, + newProjectId: project1.to_global_id.to_s, + sampleIds: [ + sample1.to_global_id.to_s + ] }) + + assert_nil result2['errors'], 'should work and have no errors.' + + data2 = result2['data']['transferSamples'] + + assert_not_empty data2, 'transferSample should be populated when no authorization errors' + assert_empty data2['errors'] + assert_not_empty data2['samples'] + + assert_equal project1.id, sample1.reload.project.id + + assert_equal p1_sample_count, project1.samples.count + assert_equal p2_sample_count, project2.samples.count + end + + test 'transferSamples mutation should partially work with only some of the samples valid' do + project1 = projects(:project1) + project2 = projects(:project2) + + p1_sample_count = project1.samples.count + p2_sample_count = project2.samples.count + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project2.samples[0].to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_not_empty data['samples'] + assert_equal 1, data['samples'].count + + # check that 1 sample transfered + sample1 = IridaSchema.object_from_id(data['samples'][0], { expected_type: Sample }) + assert_equal project2.id, sample1.project.id + + assert_equal p1_sample_count - 1, project1.samples.count + assert_equal p2_sample_count + 1, project2.samples.count + + # check that the failed sample is in the error, and matches the one we expect to fail + assert_not_empty data['errors'] + assert_equal 1, data['errors'].count + assert data['errors'][0]['message'].include?('Samples with the following sample ids could not be transferred as they were not found in the source project:') # rubocop:disable Layout/LineLength + expected_failed_sample = project2.samples[0].id + assert data['errors'][0]['message'].include?(expected_failed_sample) + end + + test 'transferSamples mutation should not work when sample gid is invalid, partial success' do + project1 = projects(:project1) + project2 = projects(:project2) + + p1_sample_count = project1.samples.count + p2_sample_count = project2.samples.count + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + 'not a valid sample gid' + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_not_empty data['samples'] + assert_equal 1, data['samples'].count + + # check that 1 sample transfered + sample1 = IridaSchema.object_from_id(data['samples'][0], { expected_type: Sample }) + assert_equal project2.id, sample1.project.id + + assert_equal p1_sample_count - 1, project1.samples.count + assert_equal p2_sample_count + 1, project2.samples.count + + # check that the failed sample is in the error, and matches the one we expect to fail + assert_not_empty data['errors'] + assert_equal 1, data['errors'].count + assert_equal 'not a valid sample gid is not a valid IRIDA Next ID.', data['errors'][0]['message'] + assert_equal ['transferSamples'], data['errors'][0]['path'] + end + + test 'transferSamples mutation should not work when sample gid is actually a group gid, partial success' do + project1 = projects(:project1) + project2 = projects(:project2) + group1 = groups(:group_one) + + p1_sample_count = project1.samples.count + p2_sample_count = project2.samples.count + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + group1.to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_not_empty data['samples'] + assert_equal 1, data['samples'].count + + # check that 1 sample transfered + sample1 = IridaSchema.object_from_id(data['samples'][0], { expected_type: Sample }) + assert_equal project2.id, sample1.project.id + + assert_equal p1_sample_count - 1, project1.samples.count + assert_equal p2_sample_count + 1, project2.samples.count + + # check that the failed sample is in the error, and matches the one we expect to fail + assert_not_empty data['errors'] + assert_equal 1, data['errors'].count + assert_equal "#{group1.to_global_id} is not a valid ID for Sample", data['errors'][0]['message'] + assert_equal ['transferSamples'], data['errors'][0]['path'] + end + + test 'transferSamples mutation should not work when sample gid is actually a project gid, partial success' do + project1 = projects(:project1) + project2 = projects(:project2) + + p1_sample_count = project1.samples.count + p2_sample_count = project2.samples.count + + result = IridaSchema.execute(TRANSFER_SAMPLE_USING_PROJECT_ID_MUTATION, + context: { current_user: @user, token: @api_scope_token }, + variables: { projectId: project1.to_global_id.to_s, + newProjectId: project2.to_global_id.to_s, + sampleIds: [ + project1.samples[0].to_global_id.to_s, + project1.to_global_id.to_s + ] }) + + assert_nil result['errors'], 'should work and have no errors.' + + data = result['data']['transferSamples'] + + assert_not_empty data, 'transferSample should be populated when no authorization errors' + assert_not_empty data['samples'] + assert_equal 1, data['samples'].count + + # check that 1 sample transfered + sample1 = IridaSchema.object_from_id(data['samples'][0], { expected_type: Sample }) + assert_equal project2.id, sample1.project.id + + assert_equal p1_sample_count - 1, project1.samples.count + assert_equal p2_sample_count + 1, project2.samples.count + + # check that the failed sample is in the error, and matches the one we expect to fail + assert_not_empty data['errors'] + assert_equal 1, data['errors'].count + assert_equal "#{project1.to_global_id} is not a valid ID for Sample", data['errors'][0]['message'] + assert_equal ['transferSamples'], data['errors'][0]['path'] + end +end diff --git a/test/services/samples/clone_service_test.rb b/test/services/samples/clone_service_test.rb index 0e8c70f299..4c3d02738b 100644 --- a/test/services/samples/clone_service_test.rb +++ b/test/services/samples/clone_service_test.rb @@ -36,6 +36,14 @@ def setup I18n.t('services.samples.clone.same_project')) end + test 'not clone samples with not matching sample ids' do + clone_samples_params = { new_project_id: @new_project.id, sample_ids: ['gid://irida/Sample/not_a_real_id'] } + assert_empty Samples::CloneService.new(@project, @john_doe).execute(clone_samples_params[:new_project_id], + clone_samples_params[:sample_ids]) + assert_equal(I18n.t('services.samples.clone.samples_not_found', sample_ids: 'gid://irida/Sample/not_a_real_id'), + @project.errors.messages_for(:samples).first) + end + test 'authorized to clone samples from source project' do clone_samples_params = { new_project_id: @new_project.id, sample_ids: [@sample1.id, @sample2.id] } Samples::CloneService.new(@project, @john_doe).execute(clone_samples_params[:new_project_id],