diff --git a/app/services/projects/copy_service.rb b/app/services/projects/copy_service.rb index dea7dff54097..90baf52fd2d1 100644 --- a/app/services/projects/copy_service.rb +++ b/app/services/projects/copy_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) 2012-2024 the OpenProject GmbH @@ -41,9 +43,7 @@ def self.copy_dependencies ::Projects::Copy::QueriesDependentService, ::Projects::Copy::BoardsDependentService, ::Projects::Copy::OverviewDependentService, - ::Projects::Copy::StoragesDependentService, - ::Projects::Copy::StorageProjectFoldersDependentService, - ::Projects::Copy::FileLinksDependentService + ::Projects::Copy::StoragesDependentService ] end diff --git a/modules/storages/app/common/storages/errors.rb b/modules/storages/app/common/storages/errors.rb index 04efd72676ca..3e1b2cefa937 100644 --- a/modules/storages/app/common/storages/errors.rb +++ b/modules/storages/app/common/storages/errors.rb @@ -34,6 +34,8 @@ class BaseError < StandardError; end class ResolverStandardError < BaseError; end + class PollingRequired < BaseError; end + class MissingContract < ResolverStandardError; end class OperationNotSupported < ResolverStandardError; end diff --git a/modules/storages/app/common/storages/peripherals/nextcloud.rb b/modules/storages/app/common/storages/peripherals/nextcloud_registry.rb similarity index 97% rename from modules/storages/app/common/storages/peripherals/nextcloud.rb rename to modules/storages/app/common/storages/peripherals/nextcloud_registry.rb index d59f4d003d3c..d08808c33fea 100644 --- a/modules/storages/app/common/storages/peripherals/nextcloud.rb +++ b/modules/storages/app/common/storages/peripherals/nextcloud_registry.rb @@ -30,7 +30,7 @@ module Storages module Peripherals - Nextcloud = Dry::Container::Namespace.new('nextcloud') do + NextcloudRegistry = Dry::Container::Namespace.new('nextcloud') do namespace('queries') do register(:auth_check, StorageInteraction::Nextcloud::AuthCheckQuery) register(:download_link, StorageInteraction::Nextcloud::DownloadLinkQuery) diff --git a/modules/storages/app/common/storages/peripherals/one_drive.rb b/modules/storages/app/common/storages/peripherals/one_drive_registry.rb similarity index 94% rename from modules/storages/app/common/storages/peripherals/one_drive.rb rename to modules/storages/app/common/storages/peripherals/one_drive_registry.rb index 5ec1cc2975a6..b9012df94d9a 100644 --- a/modules/storages/app/common/storages/peripherals/one_drive.rb +++ b/modules/storages/app/common/storages/peripherals/one_drive_registry.rb @@ -30,7 +30,7 @@ module Storages module Peripherals - OneDrive = Dry::Container::Namespace.new('one_drive') do + OneDriveRegistry = Dry::Container::Namespace.new('one_drive') do namespace('queries') do register(:auth_check, StorageInteraction::OneDrive::AuthCheckQuery) register(:download_link, StorageInteraction::OneDrive::DownloadLinkQuery) @@ -44,6 +44,7 @@ module Peripherals end namespace('commands') do + register(:copy_template_folder, StorageInteraction::OneDrive::CopyTemplateFolderCommand) register(:create_folder, StorageInteraction::OneDrive::CreateFolderCommand) register(:delete_folder, StorageInteraction::OneDrive::DeleteFolderCommand) register(:rename_file, StorageInteraction::OneDrive::RenameFileCommand) diff --git a/modules/storages/app/common/storages/peripherals/registry.rb b/modules/storages/app/common/storages/peripherals/registry.rb index af92eee674bf..4912485a9fc7 100644 --- a/modules/storages/app/common/storages/peripherals/registry.rb +++ b/modules/storages/app/common/storages/peripherals/registry.rb @@ -44,7 +44,7 @@ def call(container, key) config.resolver = Resolver.new end - Registry.import Nextcloud - Registry.import OneDrive + Registry.import NextcloudRegistry + Registry.import OneDriveRegistry end end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/copy_template_folder_command.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/copy_template_folder_command.rb index 0157f0fb6cce..19dd527df744 100644 --- a/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/copy_template_folder_command.rb +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/copy_template_folder_command.rb @@ -26,102 +26,109 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Storages::Peripherals::StorageInteraction::Nextcloud - class CopyTemplateFolderCommand - using Storages::Peripherals::ServiceResultRefinements - - def self.call(storage:, source_path:, destination_path:) - new(storage).call(source_path:, destination_path:) - end - - def initialize(storage) - @storage = storage - end - - def call(source_path:, destination_path:) - valid_input_result = validate_inputs(source_path, destination_path).on_failure { |failure| return failure } - - remote_urls = build_origin_urls(**valid_input_result.result) - - remote_folder_does_not_exist?(remote_urls[:destination_url]).on_failure { |failure| return failure } - - copy_folder(**remote_urls).on_failure { |failure| return failure } - - get_folder_id(valid_input_result.result[:destination_path]).on_success do |command_result| - return ServiceResult - .success(result: { id: command_result.result[destination_path]['fileid'], url: remote_urls[:destination_url] }) +module Storages + module Peripherals + module StorageInteraction + module Nextcloud + class CopyTemplateFolderCommand + using ServiceResultRefinements + + def self.call(storage:, source_path:, destination_path:) + new(storage).call(source_path:, destination_path:) + end + + def initialize(storage) + @storage = storage + @data = ResultData::CopyTemplateFolder.new(id: nil, polling_url: nil, requires_polling: false) + end + + def call(source_path:, destination_path:) + valid_input_result = validate_inputs(source_path, destination_path).on_failure { |failure| return failure } + + remote_urls = build_origin_urls(**valid_input_result.result) + + remote_folder_does_not_exist?(remote_urls[:destination_url]).on_failure { |failure| return failure } + + copy_folder(**remote_urls).on_failure { |failure| return failure } + + get_folder_id(valid_input_result.result[:destination_path]).on_success do |command_result| + return ServiceResult + .success(result: @data.with(id: command_result.result[destination_path]["fileid"])) + end + end + + private + + def validate_inputs(source_path, destination_path) + if source_path.blank? || destination_path.blank? + return Util.error(:error, "Source and destination paths must be present.") + end + + ServiceResult.success(result: { source_path:, destination_path: }) + end + + def build_origin_urls(source_path:, destination_path:) + escaped_username = CGI.escapeURIComponent(@storage.username) + + source_url = Util.join_uri_path( + @storage.uri, + "remote.php/dav/files", + escaped_username, + Util.escape_path(source_path) + ) + + destination_url = Util.join_uri_path( + @storage.uri, + "remote.php/dav/files", + escaped_username, + Util.escape_path(destination_path) + ) + + { source_url:, destination_url: } + end + + def remote_folder_does_not_exist?(destination_url) + response = OpenProject.httpx.basic_auth(@storage.username, @storage.password).head(destination_url) + + case response + in { status: 200..299 } + Util.error(:conflict, "Destination folder already exists.") + in { status: 401 } + Util.error(:unauthorized, "unauthorized (validate_destination)") + in { status: 404 } + ServiceResult.success + else + Util.error(:unknown, "Unexpected response (validate_destination): #{response.code}", response) + end + end + + def copy_folder(source_url:, destination_url:) + response = OpenProject + .httpx + .basic_auth(@storage.username, @storage.password) + .request("COPY", source_url, headers: { "Destination" => destination_url, "Depth" => "infinity" }) + + case response + in { status: 200..299 } + ServiceResult.success(message: "Folder was successfully copied") + in { status: 401 } + Util.error(:unauthorized, "Unauthorized (copy_folder)") + in { status: 404 } + Util.error(:not_found, "Project folder not found (copy_folder)") + in { status: 409 } + Util.error(:conflict, Util.error_text_from_response(response)) + else + Util.error(:unknown, "Unexpected response (copy_folder): #{response.status}", response) + end + end + + def get_folder_id(destination_path) + Registry + .resolve("#{@storage.short_provider_type}.queries.file_ids") + .call(storage: @storage, path: destination_path) + end + end end end - - private - - def validate_inputs(source_path, destination_path) - if source_path.blank? || destination_path.blank? - return Util.error(:error, 'Source and destination paths must be present.') - end - - ServiceResult.success(result: { source_path:, destination_path: }) - end - - def build_origin_urls(source_path:, destination_path:) - escaped_username = CGI.escapeURIComponent(@storage.username) - - source_url = Util.join_uri_path( - @storage.uri, - "remote.php/dav/files", - escaped_username, - Util.escape_path(source_path) - ) - - destination_url = Util.join_uri_path( - @storage.uri, - "remote.php/dav/files", - escaped_username, - Util.escape_path(destination_path) - ) - - { source_url:, destination_url: } - end - - def remote_folder_does_not_exist?(destination_url) - response = OpenProject.httpx.basic_auth(@storage.username, @storage.password).head(destination_url) - - case response - in { status: 200..299 } - Util.error(:conflict, 'Destination folder already exists.') - in { status: 401 } - Util.error(:unauthorized, "unauthorized (validate_destination)") - in { status: 404 } - ServiceResult.success - else - Util.error(:unknown, "Unexpected response (validate_destination): #{response.code}", response) - end - end - - def copy_folder(source_url:, destination_url:) - response = OpenProject - .httpx - .basic_auth(@storage.username, @storage.password) - .request('COPY', source_url, headers: { 'Destination' => destination_url, 'Depth' => 'infinity' }) - - case response - in { status: 200..299 } - ServiceResult.success(message: 'Folder was successfully copied') - in { status: 401 } - Util.error(:unauthorized, "Unauthorized (copy_folder)") - in { status: 404 } - Util.error(:not_found, "Project folder not found (copy_folder)") - in { status: 409 } - Util.error(:conflict, Util.error_text_from_response(response)) - else - Util.error(:unknown, "Unexpected response (copy_folder): #{response.status}", response) - end - end - - def get_folder_id(destination_path) - Storages::Peripherals::Registry - .resolve("#{@storage.short_provider_type}.queries.file_ids") - .call(storage: @storage, path: destination_path) - end end end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/copy_template_folder_command.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/copy_template_folder_command.rb index 3c9f58be8b72..30fc2a1a3973 100644 --- a/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/copy_template_folder_command.rb +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/copy_template_folder_command.rb @@ -38,7 +38,7 @@ def self.call(storage:, source_path:, destination_path:) return ServiceResult.failure( result: :error, errors: StorageError.new(code: :error, - log_message: 'Both source and destination paths need to be present') + log_message: "Both source and destination paths need to be present") ) end @@ -47,6 +47,7 @@ def self.call(storage:, source_path:, destination_path:) def initialize(storage) @storage = storage + @data = ResultData::CopyTemplateFolder.new(id: nil, polling_url: nil, requires_polling: true) end def call(source_location:, destination_name:) @@ -62,29 +63,20 @@ def call(source_location:, destination_name:) def handle_response(response) case response in { status: 202 } - id = extract_id_from_url(response.headers[:location]) - - ServiceResult.success(result: { id:, url: response.headers[:location] }) + ServiceResult.success(result: @data.with(polling_url: response.headers[:location])) in { status: 401 } ServiceResult.failure(result: :unauthorized) in { status: 403 } ServiceResult.failure(result: :forbidden) in { status: 404 } - ServiceResult.failure(result: :not_found, message: 'Template folder not found') + ServiceResult.failure(result: :not_found, message: "Template folder not found") in { status: 409 } - ServiceResult.failure(result: :conflict, message: 'The copy would overwrite an already existing folder') + ServiceResult.failure(result: :conflict, message: "The copy would overwrite an already existing folder") else ServiceResult.failure(result: :error) end end - def extract_id_from_url(url) - extractor_regex = /.+\/items\/(?\w+)\?/ - match_data = extractor_regex.match(url) - - match_data[:item_id] if match_data - end - def copy_path_for(source_location) "/v1.0/drives/#{@storage.drive_id}/items/#{source_location}/copy?@microsoft.graph.conflictBehavior=fail" end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/result_data/copy_template_folder.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/result_data/copy_template_folder.rb new file mode 100644 index 000000000000..efacc4cb3602 --- /dev/null +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/result_data/copy_template_folder.rb @@ -0,0 +1,11 @@ +module Storages + module Peripherals + module StorageInteraction + module ResultData + CopyTemplateFolder = Data.define(:id, :polling_url, :requires_polling) do + def requires_polling? = !!requires_polling + end + end + end + end +end diff --git a/modules/storages/app/services/projects/copy/file_links_dependent_service.rb b/modules/storages/app/services/projects/copy/file_links_dependent_service.rb deleted file mode 100644 index dd8ce290da7c..000000000000 --- a/modules/storages/app/services/projects/copy/file_links_dependent_service.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module Projects::Copy - class FileLinksDependentService < Dependency - def self.human_name - I18n.t(:'projects.copy.work_package_file_links') - end - - def source_count - source.work_packages.joins(:file_links).count('file_links.id') - end - - protected - - # rubocop:disable Metrics/AbcSize - # rubocop:disable Metrics/PerceivedComplexity - def copy_dependency(*) - # If no work packages were copied, we cannot copy their file_links - return unless state.work_package_id_lookup - - source_wp_ids = state.work_package_id_lookup.keys - Storages::FileLink - .where(container_id: source_wp_ids, container_type: "WorkPackage") - .group_by(&:storage_id) - .select { |_storage_id, source_file_links| source_file_links.any? } - .each do |(storage_id, source_file_links)| - tmp = state - .copied_project_storages - .find { |item| item["source"].storage_id == storage_id } - source_project_storage = tmp['source'] - target_project_storage = tmp['target'] - storage = source_project_storage.storage - - if source_project_storage.project_folder_mode == 'automatic' - files_info_query_result = files_info_query(storage:, - file_ids: source_file_links.map(&:origin_id)) - folder_files_file_ids_deep_query_result = folder_files_file_ids_deep_query( - storage:, - location: target_project_storage.managed_project_folder_path - ) - source_file_links.each do |old_file_link| - attributes = { - storage_id: old_file_link.storage_id, - creator_id: User.current.id, - container_id: state.work_package_id_lookup[old_file_link.container_id.to_s], - container_type: 'WorkPackage', - origin_name: old_file_link.origin_name, - origin_mime_type: old_file_link.origin_mime_type - } - - original_file_location = files_info_query_result - .find { |i| i.id.to_i == old_file_link.origin_id.to_i } - .location - - attributes['origin_id'] = - if source_project_storage.file_inside_project_folder?(original_file_location) - new_file_location = original_file_location.gsub( - source_project_storage.project_folder_path_escaped, - target_project_storage.project_folder_path_escaped - ) - new_file_location = CGI.unescape(new_file_location[1..]) - folder_files_file_ids_deep_query_result[new_file_location].id - else - old_file_link.origin_id - end - Storages::FileLinks::CreateService.new(user: User.current).call(attributes) - end - else - source_file_links.each do |old_file_link| - attributes = { - storage_id: old_file_link.storage_id, - creator_id: User.current.id, - container_id: state.work_package_id_lookup[old_file_link.container_id.to_s], - container_type: 'WorkPackage', - origin_name: old_file_link.origin_name, - origin_mime_type: old_file_link.origin_mime_type, - origin_id: old_file_link.origin_id - } - Storages::FileLinks::CreateService.new(user: User.current).call(attributes) - end - end - end - end - - # rubocop:enable Metrics/AbcSize - # rubocop:enable Metrics/PerceivedComplexity - - def files_info_query(storage:, file_ids:) - Storages::Peripherals::Registry - .resolve("#{storage.short_provider_type}.queries.files_info") - .call(storage:, user: User.current, file_ids:) - .on_failure { |r| add_error!("files_info_query", r.to_active_model_errors) } - .result - end - - def folder_files_file_ids_deep_query(storage:, location:) - Storages::Peripherals::Registry - .resolve("#{storage.short_provider_type}.queries.folder_files_file_ids_deep_query") - .call(storage:, folder: Storages::Peripherals::ParentFolder.new(location)) - .on_failure { |r| add_error!("folder_files_file_ids_deep_query", r.to_active_model_errors) } - .result - end - end -end diff --git a/modules/storages/app/services/projects/copy/storage_project_folders_dependent_service.rb b/modules/storages/app/services/projects/copy/storage_project_folders_dependent_service.rb deleted file mode 100644 index 8dc78a329588..000000000000 --- a/modules/storages/app/services/projects/copy/storage_project_folders_dependent_service.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module Projects::Copy - class StorageProjectFoldersDependentService < Dependency - using Storages::Peripherals::ServiceResultRefinements - - def self.human_name - I18n.t(:label_project_storage_project_folder) - end - - def source_count - # we copy the same amount of project folders as storages - source.storages.count - end - - protected - - def copy_dependency(*) - return unless state.copied_project_storages - - state.copied_project_storages.each do |copied_project_storage| - source = copied_project_storage[:source] - target = copied_project_storage[:target] - if source.project_folder_automatic? - copy_project_folder(source, target).on_success do |copy_result| - target.update!(project_folder_id: copy_result.result[:id], project_folder_mode: 'automatic') - end - elsif source.project_folder_manual? - target.update!(project_folder_id: source.project_folder_id, project_folder_mode: 'manual') - end - end - end - - private - - def copy_project_folder(source_project_storage, destination_project_storage) - source_folder_name = source_project_storage.project_folder_location - destination_folder_name = destination_project_storage.managed_project_folder_path - - Storages::Peripherals::Registry - .resolve("#{source_project_storage.storage.short_provider_type}.commands.copy_template_folder") - .call( - storage: source_project_storage.storage, - source_path: source_folder_name, - destination_path: destination_folder_name - ).on_failure { |r| add_error!(source_folder_name, r.to_active_model_errors) } - end - end -end diff --git a/modules/storages/app/services/projects/copy/storages_dependent_service.rb b/modules/storages/app/services/projects/copy/storages_dependent_service.rb index a90c577ab15f..c6bb319e3f87 100644 --- a/modules/storages/app/services/projects/copy/storages_dependent_service.rb +++ b/modules/storages/app/services/projects/copy/storages_dependent_service.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) 2012-2024 the OpenProject GmbH @@ -52,6 +54,11 @@ def copy_dependency(*) .on_failure { |r| add_error!(source_project_storage.class.to_s, r.to_active_model_errors) } .result + Storages::CopyProjectFoldersJob.perform_later(source_id: source_project_storage.id, + target_id: project_storage_copy.id, + user_id: User.current.id, + work_package_map: state.work_package_id_lookup) + array << { source: source_project_storage, target: project_storage_copy } end end diff --git a/modules/storages/app/services/storages/file_links/copy_file_links_service.rb b/modules/storages/app/services/storages/file_links/copy_file_links_service.rb new file mode 100644 index 000000000000..dd85313c58b2 --- /dev/null +++ b/modules/storages/app/services/storages/file_links/copy_file_links_service.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Storages + module FileLinks + class CopyFileLinksService + def self.call(source:, target:, user:, work_packages_map:) + new(source:, target:, user:, work_packages_map:).call + end + + def initialize(source:, target:, user:, work_packages_map:) + @source = source + @target = target + @user = user + @work_packages_map = work_packages_map.to_h { |key, value| [key.to_i, value.to_i] } + end + + def call + source_file_links = FileLink + .includes(:creator) + .where(container_id: @work_packages_map.keys, container_type: "WorkPackage") + + if @source.project_folder_automatic? + create_managed_file_links(source_file_links) + else + create_unmanaged_file_links(source_file_links) + end + end + + private + + def create_managed_file_links(source_file_links) + location_map = build_location_map( + source_files_info(source_file_links).result, + target_files_map.result + ) + + source_file_links.find_each do |source_link| + attributes = source_link.dup.attributes + attributes.merge!( + "creator_id" => @user.id, + "container_id" => @work_packages_map[source_link.container_id], + "origin_id" => location_map[source_link.origin_id] + ) + + CreateService.new(user: @user).call(attributes).on_failure { |failed| log_errors(failed) } + end + end + + def build_location_map(source_files, target_location_map) + source_location_map = source_files.to_h { |info| [info.id, info.location] } + + source_location_map.each_with_object({}) do |(id, location), output| + target = location.gsub(@source.managed_project_folder_path, @target.managed_project_folder_path) + + output[id] = target_location_map[target] + end + end + + def source_files_info(source_file_links) + Peripherals::Registry + .resolve("#{@source.storage.short_provider_type}.queries.files_info") + .call(storage: @source.storage, user: @user, file_ids: source_file_links.pluck(:origin_id)) + end + + def target_files_map + Peripherals::Registry + .resolve("#{@source.storage.short_provider_type}.queries.folder_files_file_ids_deep_query") + .call(storage: @source.storage, folder: Peripherals::ParentFolder.new(@target.project_folder_location)) + end + + def create_unmanaged_file_links(source_file_links) + source_file_links.find_each do |source_file_link| + attributes = source_file_link.dup.attributes + + attributes["creator_id"] = @user.id + attributes["container_id"] = @work_packages_map[source_file_link.container_id] + + FileLinks::CreateService.new(user: @user).call(attributes).on_failure { |failed| log_errors(failed) } + end + end + + def log_errors(failure) + OpenProject.logger.warn failure.errors.inspect + end + end + end +end diff --git a/modules/storages/app/services/storages/project_storages/copy_project_folders_service.rb b/modules/storages/app/services/storages/project_storages/copy_project_folders_service.rb new file mode 100644 index 000000000000..3957ffe04b13 --- /dev/null +++ b/modules/storages/app/services/storages/project_storages/copy_project_folders_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Storages + module ProjectStorages + class CopyProjectFoldersService + def self.call(source:, target:) + new.call(source, target) + end + + def initialize + @data = Peripherals::StorageInteraction::ResultData::CopyTemplateFolder + .new(id: nil, polling_url: nil, requires_polling: false) + end + + def call(source, target) + return ServiceResult.success(result: @data) if source.project_folder_inactive? + return ServiceResult.success(result: @data.with(id: source.project_folder_id)) if source.project_folder_manual? + + Peripherals::Registry + .resolve("#{source.storage.short_provider_type}.commands.copy_template_folder") + .call(storage: source.storage, + source_path: source.project_folder_location, + destination_path: target.managed_project_folder_path) + end + end + end +end diff --git a/modules/storages/app/workers/storages/copy_project_folders_job.rb b/modules/storages/app/workers/storages/copy_project_folders_job.rb new file mode 100644 index 000000000000..3c71890ceb81 --- /dev/null +++ b/modules/storages/app/workers/storages/copy_project_folders_job.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Storages + class CopyProjectFoldersJob < ApplicationJob + retry_on Errors::PollingRequired, wait: 3, attempts: (Rails.env.test? ? 3 : :unlimited) + discard_on HTTPX::HTTPError + + def perform(user_id:, source_id:, target_id:, work_package_map:) + target = ProjectStorage.find(target_id) + source = ProjectStorage.find(source_id) + user = User.find(user_id) + + project_folder_result = results_from_polling || initiate_copy(source, target) + + ProjectStorages::UpdateService.new(user:, model: target) + .call(project_folder_id: project_folder_result.result.id, + project_folder_mode: source.project_folder_mode) + .on_failure { |failed| log_failure(failed) and return failed } + + FileLinks::CopyFileLinksService.call(source:, target:, user:, work_packages_map: work_package_map) + end + + private + + def initiate_copy(source, target) + ProjectStorages::CopyProjectFoldersService + .call(source:, target:) + .on_success { |success| prepare_polling(success.result, source) } + end + + def prepare_polling(result, source) + return unless result.requires_polling? + + Thread.current[job_id] = result.polling_url + raise Errors::PollingRequired, "Storage #{source.storage.name} requires polling" + end + + def polling? + !!Thread.current[job_id] + end + + def results_from_polling + return unless polling? + + response = OpenProject.httpx.get(Thread.current[job_id]).json(symbolize_keys: true) + + # FIXME: We may want to discard in case some other error is raised + raise(Errors::PollingRequired, "#{job_id} Polling not completed yet") if response[:status] != "completed" + + Thread.current[job_id] = nil + result = Peripherals::StorageInteraction::ResultData::CopyTemplateFolder.new(response[:resourceId], nil, false) + + ServiceResult.success(result:) + end + + def log_failure(failed) + return if failed.success? + + OpenProject.logger.warn failed.errors.inspect.to_s + end + end +end diff --git a/modules/storages/spec/common/storages/peripherals/storage_interaction/nextcloud/copy_template_folder_command_spec.rb b/modules/storages/spec/common/storages/peripherals/storage_interaction/nextcloud/copy_template_folder_command_spec.rb index ec589c584faa..eae9d44e0a41 100644 --- a/modules/storages/spec/common/storages/peripherals/storage_interaction/nextcloud/copy_template_folder_command_spec.rb +++ b/modules/storages/spec/common/storages/peripherals/storage_interaction/nextcloud/copy_template_folder_command_spec.rb @@ -115,8 +115,7 @@ result = subject.call(source_path:, destination_path:) expect(result).to be_success - expect(result.result[:id]).to eq('349') - expect(result.result[:url]).to eq(destination_url) + expect(result.result.id).to eq('349') end end diff --git a/modules/storages/spec/common/storages/peripherals/storage_interaction/one_drive/copy_template_folder_command_spec.rb b/modules/storages/spec/common/storages/peripherals/storage_interaction/one_drive/copy_template_folder_command_spec.rb index 0c3f85307650..ad0739670017 100644 --- a/modules/storages/spec/common/storages/peripherals/storage_interaction/one_drive/copy_template_folder_command_spec.rb +++ b/modules/storages/spec/common/storages/peripherals/storage_interaction/one_drive/copy_template_folder_command_spec.rb @@ -95,9 +95,10 @@ command_result = described_class.call(storage:, source_path:, destination_path: 'My New Folder') expect(command_result).to be_success - expect(command_result.result[:url]).to match %r + expect(command_result.result.requires_polling?).to be_truthy + expect(command_result.result.polling_url).to match %r ensure - delete_copied_folder(command_result.result[:id]) + delete_copied_folder(command_result.result.polling_url) end describe 'error handling' do @@ -190,7 +191,11 @@ def existing_folder_tuples end end - def delete_copied_folder(location) + def delete_copied_folder(url) + extractor_regex = /.+\/items\/(?\w+)\?/ + match_data = extractor_regex.match(url) + location = match_data[:item_id] + Storages::Peripherals::Registry .resolve('one_drive.commands.delete_folder') .call(storage:, location:) diff --git a/modules/storages/spec/services/storages/project_storages/copy_project_folders_service_spec.rb b/modules/storages/spec/services/storages/project_storages/copy_project_folders_service_spec.rb new file mode 100644 index 000000000000..aae1dbce88c1 --- /dev/null +++ b/modules/storages/spec/services/storages/project_storages/copy_project_folders_service_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' +require_module_spec_helper + +RSpec.describe Storages::ProjectStorages::CopyProjectFoldersService, :webmock do + let(:storage) { create(:nextcloud_storage, :as_automatically_managed) } + let(:target) { create(:project_storage, storage:) } + let(:system_user) { create(:system) } + let(:result_data) { Storages::Peripherals::StorageInteraction::ResultData::CopyTemplateFolder.new(nil, nil, false) } + + subject(:service) { described_class } + + context "with automatically managed project folders" do + let(:source) { create(:project_storage, :as_automatically_managed, storage:) } + + it 'if polling is required, returns a nil id and an url' do + Storages::Peripherals::Registry + .stub("#{source.storage.short_provider_type}.commands.copy_template_folder", + ->(storage:, source_path:, destination_path:) do + expect(storage).to eq(source.storage) + expect(source_path).to eq(source.project_folder_location) + expect(destination_path).to eq(target.managed_project_folder_path) + + # Return a success for the provider copy with no polling required + ServiceResult.success(result: result_data.with(polling_url: 'https://polling.url.de/cool/subresources')) + end) + + result = service.call(source:, target:) + + expect(result).to be_success + expect(result.result.to_h).to eq({ id: nil, polling_url: 'https://polling.url.de/cool/subresources', + requires_polling: false }) + end + end + + context "with manually managed project folders" do + let(:source) { create(:project_storage, project_folder_id: 'this_is_a_unique_id', project_folder_mode: 'manual') } + + it "succeeds" do + result = service.call(source:, target:) + expect(result).to be_success + end + + it 'returns the source folder id' do + result = service.call(source:, target:) + + expect(result.result.id).to eq(source.project_folder_id) + end + end + + context "with non-managed project folders" do + let(:source) { create(:project_storage, project_folder_id: nil, project_folder_mode: 'inactive') } + + it "succeeds" do + expect(service.call(source:, target:)).to be_success + end + + it 'returns the origin folder id (nil)' do + result = service.call(source:, target:) + + expect(result.result.id).to eq(source.project_folder_id) + end + end +end diff --git a/modules/storages/spec/workers/storages/copy_project_folders_job_spec.rb b/modules/storages/spec/workers/storages/copy_project_folders_job_spec.rb new file mode 100644 index 000000000000..b5b2a2d83111 --- /dev/null +++ b/modules/storages/spec/workers/storages/copy_project_folders_job_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' +require_module_spec_helper + +RSpec.describe Storages::CopyProjectFoldersJob, :job, :webmock do + include ActiveJob::TestHelper + + let(:storage) { create(:nextcloud_storage, :as_automatically_managed) } + let(:source) { create(:project_storage, :as_automatically_managed, storage:) } + let(:user) { create(:admin) } + + let(:source_work_packages) { create_list(:work_package, 4, project: source.project) } + + let(:target) { create(:project_storage, storage: source.storage) } + let(:target_work_packages) { create_list(:work_package, 4, project: target.project) } + + let(:work_package_map) do + source_work_packages + .pluck(:id) + .map(&:to_s) + .zip(target_work_packages.pluck(:id)) + .to_h + end + + let(:polling_url) { 'https://polling.url.de/cool/subresources' } + let(:copy_result) { Storages::Peripherals::StorageInteraction::ResultData::CopyTemplateFolder.new(nil, nil, false) } + + let(:target_deep_file_ids) do + source_file_links.each_with_object({}) do |fl, hash| + hash["#{target.managed_project_folder_path}#{fl.name}"] = "RANDOM_ID_#{fl.hash}" + end + end + + let(:source_file_links) { source_work_packages.map { |wp| create(:file_link, container: wp, storage:) } } + let(:source_file_infos) do + source_file_links.map do |fl| + Storages::StorageFileInfo.new( + status: 'ok', + status_code: 200, + id: fl.origin_id, + name: fl.name, + location: "#{source.managed_project_folder_path}#{fl.name}" + ) + end + end + + before do + # Limit the number of retries on tests + described_class.retry_on Storages::Errors::PollingRequired, wait: 1, attempts: 3 + source_file_links + end + + describe "non-automatic managed folders" do + let(:inverted_wp_map) { work_package_map.invert } + + before do + source.update(project_folder_mode: 'manual', project_folder_id: 'awesome-folder') + source.reload + end + + it 'updates the target project storage project_folder_id to match the source' do + perform_enqueued_jobs(only: described_class) do + described_class.perform_now(source_id: source.id, target_id: target.id, work_package_map:, user_id: user.id) + end + + target.reload + expect(target.project_folder_id).to eq(source.project_folder_id) + end + + it 'copies all the file link info on the corresponding work_package' do + perform_enqueued_jobs(only: described_class) do + described_class.perform_now(source_id: source.id, target_id: target.id, work_package_map:, user_id: user.id) + end + + WorkPackage.includes(:file_links).where(id: work_package_map.values).find_each do |target_wp| + expect(target_wp.file_links.count).to eq(1) + + file_link = target_wp.file_links.first + source_file_link = source_file_links.find do |fl| + fl.container_id == inverted_wp_map[target_wp.id].to_i + end + + expect(file_link.origin_name).to eq(source_file_link.origin_name) + expect(file_link.origin_id).to eq(source_file_link.origin_id) + end + end + end + + # rubocop:disable Lint/UnusedBlockArgument + describe "managed project folders" do + before do + Storages::Peripherals::Registry + .stub("#{storage.short_provider_type}.queries.folder_files_file_ids_deep_query", ->(storage:, folder:) { + ServiceResult.success(result: target_deep_file_ids) + }) + + Storages::Peripherals::Registry + .stub("#{storage.short_provider_type}.queries.files_info", ->(storage:, user:, file_ids:) { + ServiceResult.success(result: source_file_infos) + }) + + Storages::Peripherals::Registry + .stub("#{storage.short_provider_type}.commands.copy_template_folder", ->(storage:, source_path:, destination_path:) { + ServiceResult.success(result: copy_result.with(id: 'copied-folder', polling_url:)) + }) + end + + it 'copies the folders from source to target' do + perform_enqueued_jobs(only: described_class) do + described_class.perform_now(source_id: source.id, target_id: target.id, work_package_map:, user_id: user.id) + end + + target.reload + expect(target.project_folder_mode).to eq(source.project_folder_mode) + expect(target.project_folder_id).to eq('copied-folder') + end + + it 'creates the file links pointing to the newly copied files' do + perform_enqueued_jobs(only: described_class) do + described_class.perform_now(source_id: source.id, target_id: target.id, work_package_map:, user_id: user.id) + end + + Storages::FileLink.where(container: target_work_packages).find_each do |file_link| + expect(file_link.origin_id).to eq(target_deep_file_ids["#{target.managed_project_folder_path}#{file_link.name}"]) + end + end + end + + context "when the storage requires polling" do + before do + Storages::Peripherals::Registry + .stub("#{storage.short_provider_type}.commands.copy_template_folder", ->(storage:, source_path:, destination_path:) { + ServiceResult.success(result: copy_result.with(polling_url:, requires_polling: true)) + }) + + Storages::Peripherals::Registry + .stub("#{storage.short_provider_type}.queries.folder_files_file_ids_deep_query", ->(storage:, folder:) { + ServiceResult.success(result: target_deep_file_ids) + }) + + Storages::Peripherals::Registry + .stub("#{storage.short_provider_type}.queries.files_info", ->(storage:, user:, file_ids:) { + ServiceResult.success(result: source_file_infos) + }) + end + + it 'raises a Storages::Errors::PollingRequired' do + perform_enqueued_jobs(only: described_class) do + expect do + described_class.perform_now(source_id: source.id, target_id: target.id, work_package_map:, user_id: user.id) + end.to raise_error Storages::Errors::PollingRequired + end + end + + it 'stores the polling url on the current thread' do + job = described_class.new + + perform_enqueued_jobs(only: described_class) do + expect do + job.perform(source_id: source.id, target_id: target.id, work_package_map:, user_id: user.id) + end.to raise_error Storages::Errors::PollingRequired + end + + expect(Thread.current[job.job_id]).to eq(polling_url) + end + + context 'when the polling completes' do + let(:copy_incomplete_response) do + { operation: "ItemCopy", percentageComplete: 27.8, status: "inProgress" }.to_json + end + + let(:copy_complete_response) do + { percentageComplete: 100.0, resourceId: "01MOWKYVJML57KN2ANMBA3JZJS2MBGC7KM", status: "completed" }.to_json + end + + before do + stub_request(:get, polling_url) + .and_return( + { status: 202, body: copy_incomplete_response, headers: { 'Content-Type' => 'application/json' } }, + { status: 202, body: copy_complete_response, headers: { 'Content-Type' => 'application/json' } } + ) + end + + it 'updates the storages' do + perform_enqueued_jobs(only: described_class) do + described_class.perform_now(source_id: source.id, target_id: target.id, work_package_map:, user_id: user.id) + end + + target.reload + expect(target.project_folder_mode).to eq(source.project_folder_mode) + expect(target.project_folder_id).to eq('01MOWKYVJML57KN2ANMBA3JZJS2MBGC7KM') + end + + it 'handles re-enqueues and polling' do + perform_enqueued_jobs(only: described_class) do + described_class.perform_now(source_id: source.id, target_id: target.id, work_package_map:, user_id: user.id) + end + + performed_job = ActiveJob::Base.queue_adapter.performed_jobs.find { |jobs| jobs['job_class'] == described_class.to_s } + expect(performed_job['exception_executions']['[Storages::Errors::PollingRequired]']).to eq(2) + expect(performed_job['executions']).to eq(1) + end + end + end + # rubocop:enable Lint/UnusedBlockArgument +end diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 825d4f7f506a..3ecd33e6a015 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -137,7 +137,10 @@ let(:parent_field) { FormFields::SelectFormField.new :parent } let(:storage) { create(:nextcloud_storage) } - let(:project_storage) { create(:project_storage, project:, storage:) } + let(:project_storage) do + create(:project_storage, project:, storage:) + end + let(:file_link) { create(:file_link, container: work_package, storage:) } before do diff --git a/spec/services/projects/copy_service_integration_spec.rb b/spec/services/projects/copy_service_integration_spec.rb index c95a328eb85e..64cb1ef9d6b6 100644 --- a/spec/services/projects/copy_service_integration_spec.rb +++ b/spec/services/projects/copy_service_integration_spec.rb @@ -102,8 +102,6 @@ boards overview storages - storage_project_folders - file_links ) ) end @@ -234,45 +232,6 @@ let(:storage1) { source_automatic_project_storage.storage } let(:storage2) { source_manual_project_storage.storage } # rubocop:enable RSpec/IndexedLet - let(:host) { storage1.host } - let!(:file_outside_project_folder_link) do - create(:file_link, - origin_id: "100", - origin_name: "file_name1.txt", - container: source_wp, - storage: storage1) - end - let!(:file_inside_automatic_project_folder_link) do - create(:file_link, - origin_id: "101", - origin_name: "file_name2.txt", - container: source_wp, - storage: storage1) - end - let!(:folder_inside_automatic_project_folder_link) do - create(:file_link, - origin_id: "103", - origin_name: "This is a folder", - container: source_wp, - storage: storage1) - end - let!(:file_inside_manual_project_folder_link) do - create(:file_link, - origin_id: "102", - origin_name: "file_name3.txt", - container: source_wp, - storage: storage2) - end - let!(:oauth_client) do - create(:oauth_client, - client_id: "nwz34rWsolvJvchfQ1bVHXfMb1ETK89lCBgzrLhWx3ACW5nKfmdcyf5ftlCyKGbk", - client_secret: "A08n6CRBOOr41iqkWRynnP6BbmEnau7LeP9t9xrIbiYX46iXgmIZgqhJoDFjUMEq", - integration: storage1) - end - let!(:oauth_client_token) { create(:oauth_client_token, oauth_client:, user: current_user) } - let(:destination_url) { %r{#{host}/remote.php/dav/files/OpenProject/OpenProject/Target%20Project%20Name%20} } - let(:source_url) { %r{#{host}/remote.php/dav/files/OpenProject/OpenProject/Source%20Project%20Name%20} } - let(:new_project_folder_id) { "819" } shared_let(:source_automatic_project_storage) do storage = create(:nextcloud_storage) @@ -284,161 +243,6 @@ create(:project_storage, storage:, project: source, project_folder_id: '345', project_folder_mode: 'manual') end - before do - stub_request(:head, destination_url).to_return(status: 404) - stub_request(:copy, source_url).to_return(status: 201) - stub_request(:propfind, destination_url).with(headers: { 'Depth' => '1' }).to_return do |_request| - project_id = Project.where(name: "Target Project Name").pick(:id) - body = <<~XML - - - - /remote.php/dav/files/OpenProject/OpenProject/Target%20Project%20Name%20(#{project_id})/ - - - #{new_project_folder_id} - - HTTP/1.1 200 OK - - - - XML - { status: 200, body:, headers: {} } - end - stub_request(:propfind, destination_url).with(headers: { 'Depth' => 'infinity' }).to_return do |_request| - project_id = Project.where(name: "Target Project Name").pick(:id) - body = <<~XML - - - - /remote.php/dav/files/OpenProject/OpenProject/Target%20Project%20Name%20(#{project_id})/ - - - #{new_project_folder_id} - - HTTP/1.1 200 OK - - - - - - HTTP/1.1 404 Not Found - - - - /remote.php/dav/files/OpenProject/OpenProject/Target%20Project%20Name%20(#{project_id})/#{file_inside_automatic_project_folder_link.origin_name} - - - 430 - - HTTP/1.1 200 OK - - - - - - HTTP/1.1 404 Not Found - - - - /remote.php/dav/files/OpenProject/OpenProject/Target%20Project%20Name%20(#{project_id})/#{folder_inside_automatic_project_folder_link.origin_name}/ - - - 431 - - HTTP/1.1 200 OK - - - - - - HTTP/1.1 404 Not Found - - - - XML - { status: 200, body:, headers: {} } - end - - filesinfo_response_body = <<~JSON - { - "ocs": { - "meta": { - "status": "ok", - "statuscode": 100, - "message": "OK", - "totalitems": "", - "itemsperpage": "" - }, - "data": { - "#{file_outside_project_folder_link.origin_id}": { - "status": "OK", - "statuscode": 200, - "id": #{file_outside_project_folder_link.origin_id}, - "name": "#{file_outside_project_folder_link.origin_name}", - "mtime": 1688632254, - "ctime": 0, - "mimetype": "application\\/pdf", - "size": 15181180, - "owner_name": "admin", - "owner_id": "admin", - "trashed": false, - "modifier_name": "admin", - "modifier_id": "admin", - "dav_permissions": "RGDNVW", - "path": "files\\/#{file_outside_project_folder_link.origin_name}" - }, - "#{file_inside_automatic_project_folder_link.origin_id}": { - "status": "OK", - "statuscode": 200, - "id": #{file_inside_automatic_project_folder_link.origin_id}, - "name": "#{file_inside_automatic_project_folder_link.origin_name}", - "mtime": 1689687843, - "ctime": 0, - "mimetype": "image\\/jpeg", - "size": 94064, - "owner_name": "admin", - "owner_id": "admin", - "trashed": false, - "modifier_name": null, - "modifier_id": null, - "dav_permissions": "RMGDNVW", - "path": "files\\/OpenProject\\/Source Project Name (#{source.id})\\/#{file_inside_automatic_project_folder_link.origin_name}" - }, - "#{folder_inside_automatic_project_folder_link.origin_id}": { - "status": "OK", - "statuscode": 200, - "id": #{folder_inside_automatic_project_folder_link.origin_id}, - "name": "#{folder_inside_automatic_project_folder_link.origin_name}", - "mtime": 1689687111, - "ctime": 0, - "mimetype": "application\\/x-op-directory", - "size": 0, - "owner_name": "admin", - "owner_id": "admin", - "trashed": false, - "modifier_name": null, - "modifier_id": null, - "dav_permissions": "RMGDNVCK", - "path": "files\\/OpenProject\\/Source Project Name (#{source.id})\\/#{folder_inside_automatic_project_folder_link.origin_name}" - } - } - } - } - JSON - stub_request(:post, "#{host}/ocs/v1.php/apps/integration_openproject/filesinfo") - .with(body: '{"fileIds":["100","101","103"]}') - .to_return(status: 200, body: filesinfo_response_body, headers: { 'Content-Type' => 'application/json' }) - end - # rubocop:disable RSpec/ExampleLength # rubocop:disable RSpec/MultipleExpectations it 'copies all dependencies and set attributes' do @@ -483,36 +287,17 @@ expect(automatic_project_storage_copy.id).not_to eq(source_automatic_project_storage.id) expect(automatic_project_storage_copy.project_id).to eq(project_copy.id) expect(automatic_project_storage_copy.creator_id).to eq(current_user.id) - expect(automatic_project_storage_copy.project_folder_id).to eq("819") - expect(automatic_project_storage_copy.project_folder_mode).to eq('automatic') + expect(automatic_project_storage_copy.project_folder_id).to be_nil + expect(automatic_project_storage_copy.project_folder_mode).to eq('inactive') manual_project_storage_copy = project_copy.project_storages.find_by(storage: storage2) expect(manual_project_storage_copy.id).not_to eq(source_manual_project_storage.id) expect(manual_project_storage_copy.project_id).to eq(project_copy.id) expect(manual_project_storage_copy.creator_id).to eq(current_user.id) - expect(manual_project_storage_copy.project_folder_id).to eq("345") - expect(manual_project_storage_copy.project_folder_mode).to eq('manual') - - wp_copy = project_copy.work_packages.where(subject: "source wp").first - expect(wp_copy.file_links.count).to eq(4) - file_outside_project_folder_link_copy = wp_copy.file_links.find_by(origin_name: "file_name1.txt") - file_inside_automatic_project_folder_link_copy = wp_copy.file_links.find_by(origin_name: "file_name2.txt") - file_inside_manual_project_folder_link_copy = wp_copy.file_links.find_by(origin_name: "file_name3.txt") - folder_inside_automatic_project_folder_link_copy = wp_copy.file_links.find_by(origin_name: "This is a folder") - expect(file_outside_project_folder_link_copy.id).not_to eq(file_outside_project_folder_link.id) - expect(file_outside_project_folder_link_copy.origin_id).to eq(file_outside_project_folder_link.origin_id) - expect(file_outside_project_folder_link_copy.storage_id).to eq(file_outside_project_folder_link.storage_id) - expect(file_inside_automatic_project_folder_link_copy.id).not_to eq(file_inside_automatic_project_folder_link.id) - expect(file_inside_automatic_project_folder_link_copy.origin_id).to eq("430") - expect(file_inside_automatic_project_folder_link_copy.storage_id) - .to eq(file_inside_automatic_project_folder_link.storage_id) - expect(folder_inside_automatic_project_folder_link_copy.id).not_to eq(folder_inside_automatic_project_folder_link.id) - expect(folder_inside_automatic_project_folder_link_copy.origin_id).to eq("431") - expect(folder_inside_automatic_project_folder_link_copy.storage_id) - .to eq(folder_inside_automatic_project_folder_link.storage_id) - expect(file_inside_manual_project_folder_link_copy.id).not_to eq(file_inside_manual_project_folder_link.id) - expect(file_inside_manual_project_folder_link_copy.origin_id).to eq("102") - expect(file_inside_manual_project_folder_link_copy.storage_id).to eq(file_inside_manual_project_folder_link.storage_id) + expect(manual_project_storage_copy.project_folder_id).to be_nil + expect(manual_project_storage_copy.project_folder_mode).to eq('inactive') + + expect(Storages::CopyProjectFoldersJob).to have_been_enqueued.exactly(2).times end # rubocop:enable RSpec/ExampleLength # rubocop:enable RSpec/MultipleExpectations