From 369274c9ee82eed6010a1a3b9cc5bac1a9926e2c Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 26 Mar 2024 12:21:56 +0100 Subject: [PATCH] feat(files_versions): Add listener and interfaces to allow versions migration across storages Signed-off-by: Louis Chemineau --- .../composer/composer/autoload_classmap.php | 2 + .../composer/composer/autoload_static.php | 2 + .../lib/AppInfo/Application.php | 6 + .../lib/Listener/FileEventsListener.php | 21 +++ .../Listener/VersionStorageMoveListener.php | 154 ++++++++++++++++++ .../lib/Versions/IMetadataVersion.php | 8 + .../lib/Versions/IVersionManager.php | 8 + .../lib/Versions/IVersionsImporterBackend.php | 50 ++++++ .../lib/Versions/LegacyVersionsBackend.php | 75 ++++++++- apps/files_versions/lib/Versions/Version.php | 4 + .../lib/Versions/VersionManager.php | 1 - cypress/e2e/files/FilesUtils.ts | 49 ++++-- cypress/e2e/files/live_photos.cy.ts | 28 +--- .../e2e/files_versions/filesVersionsUtils.ts | 11 +- .../version_cross_share_move_and_copy.cy.ts | 119 ++++++++++++++ .../e2e/files_versions/version_download.cy.ts | 12 +- .../files_versions/version_expiration.cy.ts | 6 +- .../files_versions/version_restoration.cy.ts | 12 +- cypress/support/commands.ts | 2 +- 19 files changed, 511 insertions(+), 59 deletions(-) create mode 100644 apps/files_versions/lib/Listener/VersionStorageMoveListener.php create mode 100644 apps/files_versions/lib/Versions/IVersionsImporterBackend.php create mode 100644 cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts diff --git a/apps/files_versions/composer/composer/autoload_classmap.php b/apps/files_versions/composer/composer/autoload_classmap.php index c7fa952de27..94256f79da1 100644 --- a/apps/files_versions/composer/composer/autoload_classmap.php +++ b/apps/files_versions/composer/composer/autoload_classmap.php @@ -22,6 +22,7 @@ return array( 'OCA\\Files_Versions\\Listener\\LoadAdditionalListener' => $baseDir . '/../lib/Listener/LoadAdditionalListener.php', 'OCA\\Files_Versions\\Listener\\LoadSidebarListener' => $baseDir . '/../lib/Listener/LoadSidebarListener.php', 'OCA\\Files_Versions\\Listener\\VersionAuthorListener' => $baseDir . '/../lib/Listener/VersionAuthorListener.php', + 'OCA\\Files_Versions\\Listener\\VersionStorageMoveListener' => $baseDir . '/../lib/Listener/VersionStorageMoveListener.php', 'OCA\\Files_Versions\\Migration\\Version1020Date20221114144058' => $baseDir . '/../lib/Migration/Version1020Date20221114144058.php', 'OCA\\Files_Versions\\Sabre\\Plugin' => $baseDir . '/../lib/Sabre/Plugin.php', 'OCA\\Files_Versions\\Sabre\\RestoreFolder' => $baseDir . '/../lib/Sabre/RestoreFolder.php', @@ -41,6 +42,7 @@ return array( 'OCA\\Files_Versions\\Versions\\IVersion' => $baseDir . '/../lib/Versions/IVersion.php', 'OCA\\Files_Versions\\Versions\\IVersionBackend' => $baseDir . '/../lib/Versions/IVersionBackend.php', 'OCA\\Files_Versions\\Versions\\IVersionManager' => $baseDir . '/../lib/Versions/IVersionManager.php', + 'OCA\\Files_Versions\\Versions\\IVersionsImporterBackend' => $baseDir . '/../lib/Versions/IVersionsImporterBackend.php', 'OCA\\Files_Versions\\Versions\\LegacyVersionsBackend' => $baseDir . '/../lib/Versions/LegacyVersionsBackend.php', 'OCA\\Files_Versions\\Versions\\Version' => $baseDir . '/../lib/Versions/Version.php', 'OCA\\Files_Versions\\Versions\\VersionManager' => $baseDir . '/../lib/Versions/VersionManager.php', diff --git a/apps/files_versions/composer/composer/autoload_static.php b/apps/files_versions/composer/composer/autoload_static.php index 988b8e27cbe..1ac235d936d 100644 --- a/apps/files_versions/composer/composer/autoload_static.php +++ b/apps/files_versions/composer/composer/autoload_static.php @@ -37,6 +37,7 @@ class ComposerStaticInitFiles_Versions 'OCA\\Files_Versions\\Listener\\LoadAdditionalListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalListener.php', 'OCA\\Files_Versions\\Listener\\LoadSidebarListener' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarListener.php', 'OCA\\Files_Versions\\Listener\\VersionAuthorListener' => __DIR__ . '/..' . '/../lib/Listener/VersionAuthorListener.php', + 'OCA\\Files_Versions\\Listener\\VersionStorageMoveListener' => __DIR__ . '/..' . '/../lib/Listener/VersionStorageMoveListener.php', 'OCA\\Files_Versions\\Migration\\Version1020Date20221114144058' => __DIR__ . '/..' . '/../lib/Migration/Version1020Date20221114144058.php', 'OCA\\Files_Versions\\Sabre\\Plugin' => __DIR__ . '/..' . '/../lib/Sabre/Plugin.php', 'OCA\\Files_Versions\\Sabre\\RestoreFolder' => __DIR__ . '/..' . '/../lib/Sabre/RestoreFolder.php', @@ -56,6 +57,7 @@ class ComposerStaticInitFiles_Versions 'OCA\\Files_Versions\\Versions\\IVersion' => __DIR__ . '/..' . '/../lib/Versions/IVersion.php', 'OCA\\Files_Versions\\Versions\\IVersionBackend' => __DIR__ . '/..' . '/../lib/Versions/IVersionBackend.php', 'OCA\\Files_Versions\\Versions\\IVersionManager' => __DIR__ . '/..' . '/../lib/Versions/IVersionManager.php', + 'OCA\\Files_Versions\\Versions\\IVersionsImporterBackend' => __DIR__ . '/..' . '/../lib/Versions/IVersionsImporterBackend.php', 'OCA\\Files_Versions\\Versions\\LegacyVersionsBackend' => __DIR__ . '/..' . '/../lib/Versions/LegacyVersionsBackend.php', 'OCA\\Files_Versions\\Versions\\Version' => __DIR__ . '/..' . '/../lib/Versions/Version.php', 'OCA\\Files_Versions\\Versions\\VersionManager' => __DIR__ . '/..' . '/../lib/Versions/VersionManager.php', diff --git a/apps/files_versions/lib/AppInfo/Application.php b/apps/files_versions/lib/AppInfo/Application.php index e6870139ee2..f9ab1883a6e 100644 --- a/apps/files_versions/lib/AppInfo/Application.php +++ b/apps/files_versions/lib/AppInfo/Application.php @@ -37,6 +37,7 @@ use OCA\Files_Versions\Listener\FileEventsListener; use OCA\Files_Versions\Listener\LoadAdditionalListener; use OCA\Files_Versions\Listener\LoadSidebarListener; use OCA\Files_Versions\Listener\VersionAuthorListener; +use OCA\Files_Versions\Listener\VersionStorageMoveListener; use OCA\Files_Versions\Versions\IVersionManager; use OCA\Files_Versions\Versions\VersionManager; use OCP\Accounts\IAccountManager; @@ -109,6 +110,11 @@ class Application extends App implements IBootstrap { $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class); $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class); + $context->registerEventListener(BeforeNodeRenamedEvent::class, VersionStorageMoveListener::class); + $context->registerEventListener(NodeRenamedEvent::class, VersionStorageMoveListener::class); + $context->registerEventListener(BeforeNodeCopiedEvent::class, VersionStorageMoveListener::class); + $context->registerEventListener(NodeCopiedEvent::class, VersionStorageMoveListener::class); + $context->registerEventListener(NodeCreatedEvent::class, FileEventsListener::class); $context->registerEventListener(BeforeNodeTouchedEvent::class, FileEventsListener::class); $context->registerEventListener(NodeTouchedEvent::class, FileEventsListener::class); diff --git a/apps/files_versions/lib/Listener/FileEventsListener.php b/apps/files_versions/lib/Listener/FileEventsListener.php index 24a21b22549..3273f1f9c40 100644 --- a/apps/files_versions/lib/Listener/FileEventsListener.php +++ b/apps/files_versions/lib/Listener/FileEventsListener.php @@ -300,6 +300,13 @@ class FileEventsListener implements IEventListener { * of the stored versions along the actual file */ public function rename_hook(Node $source, Node $target): void { + $sourceBackend = $this->versionManager->getBackendForStorage($source->getParent()->getStorage()); + $targetBackend = $this->versionManager->getBackendForStorage($target->getStorage()); + // If different backends, do nothing. + if ($sourceBackend !== $targetBackend) { + return; + } + $oldPath = $this->getPathForNode($source); $newPath = $this->getPathForNode($target); Storage::renameOrCopy($oldPath, $newPath, 'rename'); @@ -312,6 +319,13 @@ class FileEventsListener implements IEventListener { * the stored versions to the new location */ public function copy_hook(Node $source, Node $target): void { + $sourceBackend = $this->versionManager->getBackendForStorage($source->getParent()->getStorage()); + $targetBackend = $this->versionManager->getBackendForStorage($target->getStorage()); + // If different backends, do nothing. + if ($sourceBackend !== $targetBackend) { + return; + } + $oldPath = $this->getPathForNode($source); $newPath = $this->getPathForNode($target); Storage::renameOrCopy($oldPath, $newPath, 'copy'); @@ -325,6 +339,13 @@ class FileEventsListener implements IEventListener { * */ public function pre_renameOrCopy_hook(Node $source, Node $target): void { + $sourceBackend = $this->versionManager->getBackendForStorage($source->getStorage()); + $targetBackend = $this->versionManager->getBackendForStorage($target->getParent()->getStorage()); + // If different backends, do nothing. + if ($sourceBackend !== $targetBackend) { + return; + } + // if we rename a movable mount point, then the versions don't have // to be renamed $oldPath = $this->getPathForNode($source); diff --git a/apps/files_versions/lib/Listener/VersionStorageMoveListener.php b/apps/files_versions/lib/Listener/VersionStorageMoveListener.php new file mode 100644 index 00000000000..1648bd403d2 --- /dev/null +++ b/apps/files_versions/lib/Listener/VersionStorageMoveListener.php @@ -0,0 +1,154 @@ + + * + * @author Louis Chmn + * + * @license GNU AGPL-3.0-or-later + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OCA\Files_Versions\Listener; + +use Exception; +use OC\Files\Node\NonExistingFile; +use OCA\Files_Versions\Versions\IVersionBackend; +use OCA\Files_Versions\Versions\IVersionManager; +use OCA\Files_Versions\Versions\IVersionsImporterBackend; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\AbstractNodesEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\Events\Node\NodeRenamedEvent; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\Storage\IStorage; +use OCP\IUser; +use OCP\IUserSession; + +/** @template-implements IEventListener */ +class VersionStorageMoveListener implements IEventListener { + /** @var File[] */ + private array $movedNodes = []; + + public function __construct( + private IVersionManager $versionManager, + private IUserSession $userSession, + ) { + } + + /** + * @abstract Moves version across storages if necessary. + * @throws Exception No user in session + */ + public function handle(Event $event): void { + if (!($event instanceof AbstractNodesEvent)) { + return; + } + + $source = $event->getSource(); + $target = $event->getTarget(); + + $sourceStorage = $this->getNodeStorage($source); + $targetStorage = $this->getNodeStorage($target); + + $sourceBackend = $this->versionManager->getBackendForStorage($sourceStorage); + $targetBackend = $this->versionManager->getBackendForStorage($targetStorage); + + // If same backend, nothing to do. + if ($sourceBackend === $targetBackend) { + return; + } + + $user = $this->userSession->getUser() ?? $source->getOwner(); + + if ($user === null) { + throw new Exception("Cannot move versions across storages without a user."); + } + + if ($event instanceof BeforeNodeRenamedEvent) { + $this->recursivelyPrepareMove($source); + } elseif ($event instanceof NodeRenamedEvent || $event instanceof NodeCopiedEvent) { + $this->recursivelyHandleMoveOrCopy($event, $user, $source, $target, $sourceBackend, $targetBackend); + } + } + + /** + * Store all sub files in this->movedNodes so their info can be used after the operation. + */ + private function recursivelyPrepareMove(Node $source): void { + if ($source instanceof File) { + $this->movedNodes[$source->getId()] = $source; + } elseif ($source instanceof Folder) { + foreach ($source->getDirectoryListing() as $child) { + $this->recursivelyPrepareMove($child); + } + } + } + + /** + * Call handleMoveOrCopy on each sub files + * @param NodeRenamedEvent|NodeCopiedEvent $event + */ + private function recursivelyHandleMoveOrCopy(Event $event, IUser $user, ?Node $source, Node $target, IVersionBackend $sourceBackend, IVersionBackend $targetBackend): void { + if ($target instanceof File) { + if ($event instanceof NodeRenamedEvent) { + $source = $this->movedNodes[$target->getId()]; + } + + /** @var File $source */ + $this->handleMoveOrCopy($event, $user, $source, $target, $sourceBackend, $targetBackend); + } elseif ($target instanceof Folder) { + /** @var Folder $source */ + foreach ($target->getDirectoryListing() as $targetChild) { + if ($event instanceof NodeCopiedEvent) { + $sourceChild = $source->get($targetChild->getName()); + } else { + $sourceChild = null; + } + + $this->recursivelyHandleMoveOrCopy($event, $user, $sourceChild, $targetChild, $sourceBackend, $targetBackend); + } + } + } + + /** + * Called only during NodeRenamedEvent or NodeCopiedEvent + * Will send the source node versions to the new backend, and then delete them from the old backend. + * @param NodeRenamedEvent|NodeCopiedEvent $event + */ + private function handleMoveOrCopy(Event $event, IUser $user, File $source, File $target, IVersionBackend $sourceBackend, IVersionBackend $targetBackend): void { + if ($targetBackend instanceof IVersionsImporterBackend) { + $versions = $sourceBackend->getVersionsForFile($user, $source); + $targetBackend->importVersionsForFile($user, $source, $target, $versions); + } + + if ($event instanceof NodeRenamedEvent && $sourceBackend instanceof IVersionsImporterBackend) { + $sourceBackend->clearVersionsForFile($user, $source, $target); + } + } + + private function getNodeStorage(Node $node): IStorage { + if ($node instanceof NonExistingFile) { + return $node->getParent()->getStorage(); + } else { + return $node->getStorage(); + } + } +} diff --git a/apps/files_versions/lib/Versions/IMetadataVersion.php b/apps/files_versions/lib/Versions/IMetadataVersion.php index 44f8d904147..40ee827012a 100644 --- a/apps/files_versions/lib/Versions/IMetadataVersion.php +++ b/apps/files_versions/lib/Versions/IMetadataVersion.php @@ -28,6 +28,14 @@ namespace OCA\Files_Versions\Versions; * @since 29.0.0 */ interface IMetadataVersion { + /** + * retrieves the all the metadata + * + * @return string[] + * @since 29.0.0 + */ + public function getMetadata(): array; + /** * retrieves the metadata value from our $key param * diff --git a/apps/files_versions/lib/Versions/IVersionManager.php b/apps/files_versions/lib/Versions/IVersionManager.php index afc3046fa48..ee5d7abeb0c 100644 --- a/apps/files_versions/lib/Versions/IVersionManager.php +++ b/apps/files_versions/lib/Versions/IVersionManager.php @@ -25,6 +25,8 @@ declare(strict_types=1); */ namespace OCA\Files_Versions\Versions; +use OCP\Files\Storage\IStorage; + /** * @since 15.0.0 */ @@ -37,4 +39,10 @@ interface IVersionManager extends IVersionBackend { * @since 15.0.0 */ public function registerBackend(string $storageType, IVersionBackend $backend); + + /** + * @throws BackendNotFoundException + * @since 29.0.0 + */ + public function getBackendForStorage(IStorage $storage): IVersionBackend; } diff --git a/apps/files_versions/lib/Versions/IVersionsImporterBackend.php b/apps/files_versions/lib/Versions/IVersionsImporterBackend.php new file mode 100644 index 00000000000..45649268107 --- /dev/null +++ b/apps/files_versions/lib/Versions/IVersionsImporterBackend.php @@ -0,0 +1,50 @@ + + * + * @author Louis Chmn + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\Files_Versions\Versions; + +use OCP\Files\Node; +use OCP\IUser; + +/** + * @since 29.0.0 + */ +interface IVersionsImporterBackend { + /** + * Import the given versions for the target file. + * + * @param Node $source - The source might not exist anymore. + * @param IVersion[] $versions + * @since 29.0.0 + */ + public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void; + + /** + * Clear all versions for a file + * + * @since 29.0.0 + */ + public function clearVersionsForFile(IUser $user, Node $source, Node $target): void; +} diff --git a/apps/files_versions/lib/Versions/LegacyVersionsBackend.php b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php index dcc56225c73..deb8833c87b 100644 --- a/apps/files_versions/lib/Versions/LegacyVersionsBackend.php +++ b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php @@ -27,6 +27,7 @@ declare(strict_types=1); namespace OCA\Files_Versions\Versions; +use Exception; use OC\Files\View; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\Files_Sharing\ISharedStorage; @@ -45,14 +46,16 @@ use OCP\Files\Storage\IStorage; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Log\LoggerInterface; -class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend, INeedSyncVersionBackend, IMetadataVersionBackend { +class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend, INeedSyncVersionBackend, IMetadataVersionBackend, IVersionsImporterBackend { public function __construct( private IRootFolder $rootFolder, private IUserManager $userManager, private VersionsMapper $versionsMapper, private IMimeTypeLoader $mimeTypeLoader, private IUserSession $userSession, + private LoggerInterface $logger, ) { } @@ -304,4 +307,74 @@ class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend $versionEntity->setMetadataValue($key, $value); $this->versionsMapper->update($versionEntity); } + + + /** + * @inheritdoc + */ + public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $relativePath = $userFolder->getRelativePath($target->getPath()); + + if ($relativePath === null) { + throw new \Exception('Target does not have a relative path' . $target->getPath()); + } + + $userView = new View('/' . $user->getUID()); + // create all parent folders + Storage::createMissingDirectories($relativePath, $userView); + Storage::scheduleExpire($user->getUID(), $relativePath); + + foreach ($versions as $version) { + // 1. Import the file in its new location. + // Nothing to do for the current version. + if ($version->getTimestamp() !== $source->getMTime()) { + $backend = $version->getBackend(); + $versionFile = $backend->getVersionFile($user, $source, $version->getRevisionId()); + $newVersionPath = 'files_versions/' . $relativePath . '.v' . $version->getTimestamp(); + + $versionContent = $versionFile->fopen('r'); + if ($versionContent === false) { + $this->logger->warning('Fail to open version file.', ['source' => $source, 'version' => $version, 'versionFile' => $versionFile]); + continue; + } + + $userView->file_put_contents($newVersionPath, $versionContent); + // ensure the file is scanned + $userView->getFileInfo($newVersionPath); + } + + // 2. Create the entity in the database + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($target->getId()); + $versionEntity->setTimestamp($version->getTimestamp()); + $versionEntity->setSize($version->getSize()); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($version->getMimetype())); + if ($version instanceof IMetadataVersion) { + $versionEntity->setMetadata($version->getMetadata()); + } + $this->versionsMapper->insert($versionEntity); + } + } + + /** + * @inheritdoc + */ + public function clearVersionsForFile(IUser $user, Node $source, Node $target): void { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + + $relativePath = $userFolder->getRelativePath($source->getPath()); + if ($relativePath === null) { + throw new Exception("Relative path not found for node with path: " . $source->getPath()); + } + + $versions = Storage::getVersions($user->getUID(), $relativePath); + /** @var Folder versionFolder */ + $versionFolder = $this->rootFolder->get('admin/files_versions'); + foreach ($versions as $version) { + $versionFolder->get($version['path'] . '.v' . (int)$version['version'])->delete(); + } + + $this->versionsMapper->deleteAllVersionsForFileId($target->getId()); + } } diff --git a/apps/files_versions/lib/Versions/Version.php b/apps/files_versions/lib/Versions/Version.php index 40f2507433d..83d27db2f7e 100644 --- a/apps/files_versions/lib/Versions/Version.php +++ b/apps/files_versions/lib/Versions/Version.php @@ -79,6 +79,10 @@ class Version implements IVersion, IMetadataVersion { return $this->user; } + public function getMetadata(): array { + return $this->metadata; + } + public function getMetadataValue(string $key): ?string { return $this->metadata[$key] ?? null; } diff --git a/apps/files_versions/lib/Versions/VersionManager.php b/apps/files_versions/lib/Versions/VersionManager.php index f12b5679fbc..754dd8523c9 100644 --- a/apps/files_versions/lib/Versions/VersionManager.php +++ b/apps/files_versions/lib/Versions/VersionManager.php @@ -203,5 +203,4 @@ class VersionManager implements IVersionManager, IDeletableVersionBackend, INeed return $result; } } - } diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 3ec3f93fd37..0467f79c53f 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -45,7 +45,7 @@ export const triggerInlineActionForFile = (filename: string, actionId: string) = getActionsForFile(filename).get(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click() } -export const moveFile = (fileName: string, dirName: string) => { +export const moveFile = (fileName: string, dirPath: string) => { getRowForFile(fileName).should('be.visible') triggerActionForFile(fileName, 'move-copy') @@ -53,26 +53,30 @@ export const moveFile = (fileName: string, dirName: string) => { // intercept the copy so we can wait for it cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile') - if (dirName === '/') { + if (dirPath === '/') { // select home folder cy.get('button[title="Home"]').should('be.visible').click() // click move cy.contains('button', 'Move').should('be.visible').click() - } else if (dirName === '.') { + } else if (dirPath === '.') { // click move cy.contains('button', 'Copy').should('be.visible').click() } else { - // select the folder - cy.get(`[data-filename="${dirName}"]`).should('be.visible').click() + const directories = dirPath.split('/') + directories.forEach((directory) => { + // select the folder + cy.get(`[data-filename="${directory}"]`).should('be.visible').click() + }) + // click move - cy.contains('button', `Move to ${dirName}`).should('be.visible').click() + cy.contains('button', `Move to ${directories.at(-1)}`).should('be.visible').click() } cy.wait('@moveFile') }) } -export const copyFile = (fileName: string, dirName: string) => { +export const copyFile = (fileName: string, dirPath: string) => { getRowForFile(fileName).should('be.visible') triggerActionForFile(fileName, 'move-copy') @@ -80,19 +84,23 @@ export const copyFile = (fileName: string, dirName: string) => { // intercept the copy so we can wait for it cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile') - if (dirName === '/') { + if (dirPath === '/') { // select home folder cy.get('button[title="Home"]').should('be.visible').click() // click copy cy.contains('button', 'Copy').should('be.visible').click() - } else if (dirName === '.') { + } else if (dirPath === '.') { // click copy cy.contains('button', 'Copy').should('be.visible').click() } else { - // select folder - cy.get(`[data-filename="${CSS.escape(dirName)}"]`).should('be.visible').click() + const directories = dirPath.split('/') + directories.forEach((directory) => { + // select the folder + cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click() + }) + // click copy - cy.contains('button', `Copy to ${dirName}`).should('be.visible').click() + cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.visible').click() } cy.wait('@copyFile') @@ -112,10 +120,21 @@ export const renameFile = (fileName: string, newFileName: string) => { cy.wait('@moveFile') } -export const navigateToFolder = (folderName: string) => { - getRowForFile(folderName).should('be.visible').find('[data-cy-files-list-row-name-link]').click() +export const navigateToFolder = (dirPath: string) => { + const directories = dirPath.split('/') + directories.forEach((directory) => { + getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click() + }) + } export const closeSidebar = () => { - cy.get('[cy-data-sidebar] .app-sidebar__close').click() + // {force: true} as it might be hidden behind toasts + cy.get('[cy-data-sidebar] .app-sidebar__close').click({ force: true }) +} + +export const clickOnBreadcumbs = (label: string) => { + cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') + cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click() + cy.wait('@propfind') } diff --git a/cypress/e2e/files/live_photos.cy.ts b/cypress/e2e/files/live_photos.cy.ts index 98babb86941..91b30f1cce5 100644 --- a/cypress/e2e/files/live_photos.cy.ts +++ b/cypress/e2e/files/live_photos.cy.ts @@ -21,17 +21,7 @@ */ import type { User } from '@nextcloud/cypress' -import { closeSidebar, copyFile, getRowForFile, getRowForFileId, renameFile, triggerActionForFile, triggerInlineActionForFileId } from './FilesUtils' - -/** - * - * @param label - */ -function refreshView(label: string) { - cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') - cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click() - cy.wait('@propfind') -} +import { clickOnBreadcumbs, closeSidebar, copyFile, getRowForFile, getRowForFileId, renameFile, triggerActionForFile, triggerInlineActionForFileId } from './FilesUtils' /** * @@ -123,7 +113,7 @@ describe('Files: Live photos', { testIsolation: true }, () => { it('Copies both files when copying the .jpg', () => { copyFile(`${randomFileName}.jpg`, '.') - refreshView('All files') + clickOnBreadcumbs('All files') getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) getRowForFile(`${randomFileName}.mov`).should('have.length', 1) @@ -133,7 +123,7 @@ describe('Files: Live photos', { testIsolation: true }, () => { it('Copies both files when copying the .mov', () => { copyFile(`${randomFileName}.mov`, '.') - refreshView('All files') + clickOnBreadcumbs('All files') getRowForFile(`${randomFileName}.mov`).should('have.length', 1) getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1) @@ -142,7 +132,7 @@ describe('Files: Live photos', { testIsolation: true }, () => { it('Moves files when moving the .jpg', () => { renameFile(`${randomFileName}.jpg`, `${randomFileName}_moved.jpg`) - refreshView('All files') + clickOnBreadcumbs('All files') getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`) getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`) @@ -150,7 +140,7 @@ describe('Files: Live photos', { testIsolation: true }, () => { it('Moves files when moving the .mov', () => { renameFile(`${randomFileName}.mov`, `${randomFileName}_moved.mov`) - refreshView('All files') + clickOnBreadcumbs('All files') getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`) getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`) @@ -158,7 +148,7 @@ describe('Files: Live photos', { testIsolation: true }, () => { it('Deletes files when deleting the .jpg', () => { triggerActionForFile(`${randomFileName}.jpg`, 'delete') - refreshView('All files') + clickOnBreadcumbs('All files') getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) getRowForFile(`${randomFileName}.mov`).should('have.length', 0) @@ -171,7 +161,7 @@ describe('Files: Live photos', { testIsolation: true }, () => { it('Block deletion when deleting the .mov', () => { triggerActionForFile(`${randomFileName}.mov`, 'delete') - refreshView('All files') + clickOnBreadcumbs('All files') getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) getRowForFile(`${randomFileName}.mov`).should('have.length', 1) @@ -186,7 +176,7 @@ describe('Files: Live photos', { testIsolation: true }, () => { triggerActionForFile(`${randomFileName}.jpg`, 'delete') cy.visit('/apps/files/trashbin') triggerInlineActionForFileId(jpgFileId, 'restore') - refreshView('Deleted files') + clickOnBreadcumbs('Deleted files') getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) getRowForFile(`${randomFileName}.mov`).should('have.length', 0) @@ -201,7 +191,7 @@ describe('Files: Live photos', { testIsolation: true }, () => { triggerActionForFile(`${randomFileName}.jpg`, 'delete') cy.visit('/apps/files/trashbin') triggerInlineActionForFileId(movFileId, 'restore') - refreshView('Deleted files') + clickOnBreadcumbs('Deleted files') getRowForFileId(jpgFileId).should('have.length', 1) getRowForFileId(movFileId).should('have.length', 1) diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts index 7f655d2c303..4ea47162f0b 100644 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -89,14 +89,11 @@ export function doesNotHaveAction(index: number, actionName: string) { toggleVersionMenu(index) } -export function assertVersionContent(filename: string, index: number, expectedContent: string) { - const downloadsFolder = Cypress.config('downloadsFolder') - +export function assertVersionContent(index: number, expectedContent: string) { + cy.intercept({ method: 'GET', times: 1, url: 'remote.php/**' }).as('downloadVersion') triggerVersionAction(index, 'download') - - return cy.readFile(path.join(downloadsFolder, filename)) - .then((versionContent) => expect(versionContent).to.equal(expectedContent)) - .then(() => cy.exec(`rm ${downloadsFolder}/${filename}`)) + cy.wait('@downloadVersion') + .then(({ response }) => expect(response?.body).to.equal(expectedContent)) } export function setupTestSharedFileFromUser(owner: User, randomFileName: string, shareOptions: Partial) { diff --git a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts new file mode 100644 index 00000000000..90381120d87 --- /dev/null +++ b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts @@ -0,0 +1,119 @@ +/** + * @copyright Copyright (c) 2024 Louis Chmn + * + * @author Louis Chmn + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import { assertVersionContent, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions, nameVersion } from './filesVersionsUtils' +import { clickOnBreadcumbs, closeSidebar, copyFile, moveFile, navigateToFolder } from '../files/FilesUtils' +import type { User } from '@nextcloud/cypress' + +/** + * + * @param filePath + */ +function assertVersionsContent(filePath: string) { + const path = filePath.split('/').slice(0, -1).join('/') + + clickOnBreadcumbs('All files') + + if (path !== '') { + navigateToFolder(path) + } + + openVersionsPanel(filePath) + + cy.get('[data-files-versions-version]').should('have.length', 3) + assertVersionContent(0, 'v3') + assertVersionContent(1, 'v2') + assertVersionContent(2, 'v1') +} + +describe('Versions cross share move and copy', () => { + let randomSharedFolderName = '' + let randomFileName = '' + let randomFilePath = '' + let alice: User + let bob: User + + before(() => { + randomSharedFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + + cy.createRandomUser() + .then((user) => { + alice = user + cy.mkdir(alice, `/${randomSharedFolderName}`) + setupTestSharedFileFromUser(alice, randomSharedFolderName, {}) + }) + .then((user) => { bob = user }) + }) + + beforeEach(() => { + randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + randomFilePath = `${randomSharedFolderName}/${randomFileName}` + uploadThreeVersions(alice, randomFilePath) + + cy.login(bob) + cy.visit('/apps/files') + navigateToFolder(randomSharedFolderName) + openVersionsPanel(randomFilePath) + nameVersion(2, 'v1') + closeSidebar() + }) + + it('Also moves versions when bob moves the file out of a received share', () => { + moveFile(randomFileName, '/') + assertVersionsContent(randomFileName) + // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata + cy.get('[data-files-versions-version]').eq(2).contains('v1') + }) + + it('Also copies versions when bob copies the file out of a received share', () => { + copyFile(randomFileName, '/') + assertVersionsContent(randomFileName) + }) + + context('When a file is in a subfolder', () => { + let randomSubFolderName + let randomSubSubFolderName + + beforeEach(() => { + randomSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + randomSubSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + clickOnBreadcumbs('All files') + cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}`) + cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}/${randomSubSubFolderName}`) + cy.login(bob) + navigateToFolder(randomSharedFolderName) + moveFile(randomFileName, `${randomSubFolderName}/${randomSubSubFolderName}`) + }) + + it('Also moves versions when bob moves the containing folder out of a received share', () => { + moveFile(randomSubFolderName, '/') + assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`) + // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata + cy.get('[data-files-versions-version]').eq(2).contains('v1') + }) + + it('Also copies versions when bob copies the containing folder out of a received share', () => { + copyFile(randomSubFolderName, '/') + assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`) + }) + }) +}) diff --git a/cypress/e2e/files_versions/version_download.cy.ts b/cypress/e2e/files_versions/version_download.cy.ts index f6a994322a8..0e4301654f0 100644 --- a/cypress/e2e/files_versions/version_download.cy.ts +++ b/cypress/e2e/files_versions/version_download.cy.ts @@ -42,9 +42,9 @@ describe('Versions download', () => { }) it('Download versions and assert their content', () => { - assertVersionContent(randomFileName, 0, 'v3') - assertVersionContent(randomFileName, 1, 'v2') - assertVersionContent(randomFileName, 2, 'v1') + assertVersionContent(0, 'v3') + assertVersionContent(1, 'v2') + assertVersionContent(2, 'v1') }) context('Download versions of shared file', () => { @@ -52,9 +52,9 @@ describe('Versions download', () => { setupTestSharedFileFromUser(user, randomFileName, { download: true }) openVersionsPanel(randomFileName) - assertVersionContent(randomFileName, 0, 'v3') - assertVersionContent(randomFileName, 1, 'v2') - assertVersionContent(randomFileName, 2, 'v1') + assertVersionContent(0, 'v3') + assertVersionContent(1, 'v2') + assertVersionContent(2, 'v1') }) it('Does not show action without download permission', () => { diff --git a/cypress/e2e/files_versions/version_expiration.cy.ts b/cypress/e2e/files_versions/version_expiration.cy.ts index a3b2eea83e1..1c1c6fc70ae 100644 --- a/cypress/e2e/files_versions/version_expiration.cy.ts +++ b/cypress/e2e/files_versions/version_expiration.cy.ts @@ -49,7 +49,7 @@ describe('Versions expiration', () => { cy.get('[data-files-versions-version]').eq(0).contains('Current version') }) - assertVersionContent(randomFileName, 0, 'v3') + assertVersionContent(0, 'v3') }) it('Expire versions v2', () => { @@ -67,7 +67,7 @@ describe('Versions expiration', () => { cy.get('[data-files-versions-version]').eq(1).contains('v1') }) - assertVersionContent(randomFileName, 0, 'v3') - assertVersionContent(randomFileName, 1, 'v1') + assertVersionContent(0, 'v3') + assertVersionContent(1, 'v1') }) }) diff --git a/cypress/e2e/files_versions/version_restoration.cy.ts b/cypress/e2e/files_versions/version_restoration.cy.ts index d9d983b0d09..72295003bba 100644 --- a/cypress/e2e/files_versions/version_restoration.cy.ts +++ b/cypress/e2e/files_versions/version_restoration.cy.ts @@ -56,9 +56,9 @@ describe('Versions restoration', () => { }) it('Downloads versions and assert there content', () => { - assertVersionContent(randomFileName, 0, 'v1') - assertVersionContent(randomFileName, 1, 'v3') - assertVersionContent(randomFileName, 2, 'v2') + assertVersionContent(0, 'v1') + assertVersionContent(1, 'v3') + assertVersionContent(2, 'v2') }) context('Restore versions of shared file', () => { @@ -76,9 +76,9 @@ describe('Versions restoration', () => { }) it('Downloads versions and assert there content', () => { - assertVersionContent(randomFileName, 0, 'v1') - assertVersionContent(randomFileName, 1, 'v3') - assertVersionContent(randomFileName, 2, 'v2') + assertVersionContent(0, 'v1') + assertVersionContent(1, 'v3') + assertVersionContent(2, 'v2') }) }) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 1d7efe52075..8d78c3db166 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -184,7 +184,7 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => { cy.log(`Created directory ${target}`, response) } catch (error) { cy.log('error', error) - throw new Error('Unable to process fixture') + throw new Error('Unable to create directory') } }) })