Merge pull request #44069 from nextcloud/artonge/fix/split_live_photo_listener

Split live photo listener to extract trashbin specific code into its own listener
fix/session/transactional-remember-me-renewal
Louis 3 months ago committed by GitHub
commit dd26fb2ba4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -68,6 +68,7 @@ return array(
'OCA\\Files\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\Files\\Search\\FilesSearchProvider' => $baseDir . '/../lib/Search/FilesSearchProvider.php',
'OCA\\Files\\Service\\DirectEditingService' => $baseDir . '/../lib/Service/DirectEditingService.php',
'OCA\\Files\\Service\\LivePhotosService' => $baseDir . '/../lib/Service/LivePhotosService.php',
'OCA\\Files\\Service\\OwnershipTransferService' => $baseDir . '/../lib/Service/OwnershipTransferService.php',
'OCA\\Files\\Service\\TagService' => $baseDir . '/../lib/Service/TagService.php',
'OCA\\Files\\Service\\UserConfig' => $baseDir . '/../lib/Service/UserConfig.php',

@ -83,6 +83,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\Files\\Search\\FilesSearchProvider' => __DIR__ . '/..' . '/../lib/Search/FilesSearchProvider.php',
'OCA\\Files\\Service\\DirectEditingService' => __DIR__ . '/..' . '/../lib/Service/DirectEditingService.php',
'OCA\\Files\\Service\\LivePhotosService' => __DIR__ . '/..' . '/../lib/Service/LivePhotosService.php',
'OCA\\Files\\Service\\OwnershipTransferService' => __DIR__ . '/..' . '/../lib/Service/OwnershipTransferService.php',
'OCA\\Files\\Service\\TagService' => __DIR__ . '/..' . '/../lib/Service/TagService.php',
'OCA\\Files\\Service\\UserConfig' => __DIR__ . '/..' . '/../lib/Service/UserConfig.php',

@ -51,7 +51,6 @@ use OCA\Files\Search\FilesSearchProvider;
use OCA\Files\Service\TagService;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent;
use OCP\Activity\IManager as IActivityManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
@ -131,11 +130,10 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(RenderReferenceEvent::class, RenderReferenceEventListener::class);
$context->registerEventListener(BeforeNodeRenamedEvent::class, SyncLivePhotosListener::class);
$context->registerEventListener(BeforeNodeDeletedEvent::class, SyncLivePhotosListener::class);
$context->registerEventListener(BeforeNodeRestoredEvent::class, SyncLivePhotosListener::class);
$context->registerEventListener(CacheEntryRemovedEvent::class, SyncLivePhotosListener::class);
$context->registerEventListener(LoadSearchPlugins::class, LoadSearchPluginsListener::class);
$context->registerEventListener(BeforeNodeCopiedEvent::class, SyncLivePhotosListener::class);
$context->registerEventListener(NodeCopiedEvent::class, SyncLivePhotosListener::class);
$context->registerEventListener(LoadSearchPlugins::class, LoadSearchPluginsListener::class);
$context->registerSearchProvider(FilesSearchProvider::class);

@ -24,9 +24,7 @@ declare(strict_types=1);
namespace OCA\Files\Listener;
use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent;
use OCA\Files_Trashbin\Trash\ITrashItem;
use OCA\Files_Trashbin\Trash\ITrashManager;
use OCA\Files\Service\LivePhotosService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Exceptions\AbortedEventException;
@ -39,10 +37,7 @@ use OCP\Files\Events\Node\NodeCopiedEvent;
use OCP\Files\Folder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
use OCP\FilesMetadata\IFilesMetadataManager;
use OCP\IUserSession;
/**
* @template-implements IEventListener<Event>
@ -52,37 +47,40 @@ class SyncLivePhotosListener implements IEventListener {
private array $pendingRenames = [];
/** @var Array<int, bool> */
private array $pendingDeletion = [];
/** @var Array<int, bool> */
private array $pendingRestores = [];
public function __construct(
private ?Folder $userFolder,
private ?IUserSession $userSession,
private ITrashManager $trashManager,
private IFilesMetadataManager $filesMetadataManager,
private LivePhotosService $livePhotosService,
) {
}
public function handle(Event $event): void {
if ($this->userFolder === null || $this->userSession === null) {
if ($this->userFolder === null) {
return;
}
$peerFile = null;
$peerFileId = null;
if ($event instanceof BeforeNodeRenamedEvent) {
$peerFile = $this->getLivePhotoPeer($event->getSource()->getId());
} elseif ($event instanceof BeforeNodeRestoredEvent) {
$peerFile = $this->getLivePhotoPeer($event->getSource()->getId());
$peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getSource()->getId());
} elseif ($event instanceof BeforeNodeDeletedEvent) {
$peerFile = $this->getLivePhotoPeer($event->getNode()->getId());
$peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getNode()->getId());
} elseif ($event instanceof CacheEntryRemovedEvent) {
$peerFile = $this->getLivePhotoPeer($event->getFileId());
$peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getFileId());
} elseif ($event instanceof BeforeNodeCopiedEvent || $event instanceof NodeCopiedEvent) {
$peerFile = $this->getLivePhotoPeer($event->getSource()->getId());
$peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getSource()->getId());
}
if ($peerFileId === null) {
return; // Not a live photo.
}
// Check the user's folder.
$peerFile = $this->userFolder->getFirstNodeById($peerFileId);
if ($peerFile === null) {
return; // not a Live Photo
return; // Peer file not found.
}
if ($event instanceof BeforeNodeRenamedEvent) {
@ -91,8 +89,6 @@ class SyncLivePhotosListener implements IEventListener {
$this->handleDeletion($event, $peerFile);
} elseif ($event instanceof CacheEntryRemovedEvent) {
$peerFile->delete();
} elseif ($event instanceof BeforeNodeRestoredEvent) {
$this->handleRestore($event, $peerFile);
} elseif ($event instanceof BeforeNodeCopiedEvent) {
$this->handleMove($event, $peerFile, true);
} elseif ($event instanceof NodeCopiedEvent) {
@ -208,114 +204,4 @@ class SyncLivePhotosListener implements IEventListener {
}
return;
}
/**
* During restore event, we trigger another recursive restore on the peer file.
* Restore operations on the .mov file directly are currently blocked.
* The event listener being singleton, we can store the current state
* of pending restores inside the 'pendingRestores' property,
* to prevent infinite recursivity.
*/
private function handleRestore(BeforeNodeRestoredEvent $event, Node $peerFile): void {
$sourceFile = $event->getSource();
if ($sourceFile->getMimetype() === 'video/quicktime') {
if (isset($this->pendingRestores[$peerFile->getId()])) {
unset($this->pendingRestores[$peerFile->getId()]);
return;
} else {
$event->abortOperation(new NotPermittedException("Cannot restore the video part of a live photo"));
}
} else {
$user = $this->userSession->getUser();
if ($user === null) {
return;
}
$peerTrashItem = $this->trashManager->getTrashNodeById($user, $peerFile->getId());
// Peer file is not in the bin, no need to restore it.
if ($peerTrashItem === null) {
return;
}
$trashRoot = $this->trashManager->listTrashRoot($user);
$trashItem = $this->getTrashItem($trashRoot, $peerFile->getInternalPath());
if ($trashItem === null) {
$event->abortOperation(new NotFoundException("Couldn't find peer file in trashbin"));
}
$this->pendingRestores[$sourceFile->getId()] = true;
try {
$this->trashManager->restoreItem($trashItem);
} catch (\Throwable $ex) {
$event->abortOperation($ex);
}
}
}
/**
* Helper method to get the associated live photo file.
* We first look for it in the user folder, and if we
* cannot find it here, we look for it in the user's trashbin.
*/
private function getLivePhotoPeer(int $nodeId): ?Node {
if ($this->userFolder === null || $this->userSession === null) {
return null;
}
try {
$metadata = $this->filesMetadataManager->getMetadata($nodeId);
} catch (FilesMetadataNotFoundException $ex) {
return null;
}
if (!$metadata->hasKey('files-live-photo')) {
return null;
}
$peerFileId = (int)$metadata->getString('files-live-photo');
// Check the user's folder.
$node = $this->userFolder->getFirstNodeById($peerFileId);
if ($node) {
return $node;
}
// Check the user's trashbin.
$user = $this->userSession->getUser();
if ($user !== null) {
$peerFile = $this->trashManager->getTrashNodeById($user, $peerFileId);
if ($peerFile !== null) {
return $peerFile;
}
}
$metadata->unset('files-live-photo');
return null;
}
/**
* There is currently no method to restore a file based on its fileId or path.
* So we have to manually find a ITrashItem from the trash item list.
* TODO: This should be replaced by a proper method in the TrashManager.
*/
private function getTrashItem(array $trashFolder, string $path): ?ITrashItem {
foreach($trashFolder as $trashItem) {
if (str_starts_with($path, "files_trashbin/files".$trashItem->getTrashPath())) {
if ($path === "files_trashbin/files".$trashItem->getTrashPath()) {
return $trashItem;
}
if ($trashItem instanceof Folder) {
$node = $this->getTrashItem($trashItem->getDirectoryListing(), $path);
if ($node !== null) {
return $node;
}
}
}
}
return null;
}
}

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Files\Service;
use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
use OCP\FilesMetadata\IFilesMetadataManager;
class LivePhotosService {
public function __construct(
private IFilesMetadataManager $filesMetadataManager,
) {
}
/**
* Get the associated live photo for a given file id
*/
public function getLivePhotoPeerId(int $fileId): ?int {
try {
$metadata = $this->filesMetadataManager->getMetadata($fileId);
} catch (FilesMetadataNotFoundException $ex) {
return null;
}
if (!$metadata->hasKey('files-live-photo')) {
return null;
}
return (int)$metadata->getString('files-live-photo');
}
}

@ -26,23 +26,28 @@
@update:open="onClose">
<!-- Settings API-->
<NcAppSettingsSection id="settings" :name="t('files', 'Files settings')">
<NcCheckboxRadioSwitch :checked="userConfig.sort_favorites_first"
<NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_favorites_first"
:checked="userConfig.sort_favorites_first"
@update:checked="setConfig('sort_favorites_first', $event)">
{{ t('files', 'Sort favorites first') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked="userConfig.sort_folders_first"
<NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_folders_first"
:checked="userConfig.sort_folders_first"
@update:checked="setConfig('sort_folders_first', $event)">
{{ t('files', 'Sort folders before files') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked="userConfig.show_hidden"
<NcCheckboxRadioSwitch data-cy-files-settings-setting="show_hidden"
:checked="userConfig.show_hidden"
@update:checked="setConfig('show_hidden', $event)">
{{ t('files', 'Show hidden files') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked="userConfig.crop_image_previews"
<NcCheckboxRadioSwitch data-cy-files-settings-setting="crop_image_previews"
:checked="userConfig.crop_image_previews"
@update:checked="setConfig('crop_image_previews', $event)">
{{ t('files', 'Crop image previews') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-if="enableGridView"
data-cy-files-settings-setting="grid_view"
:checked="userConfig.grid_view"
@update:checked="setConfig('grid_view', $event)">
{{ t('files', 'Enable the grid view') }}

@ -23,6 +23,7 @@
<template>
<NcAppSidebar v-if="file"
ref="sidebar"
cy-data-sidebar
v-bind="appSidebar"
:force-menu="true"
@close="close"

@ -24,6 +24,7 @@ return array(
'OCA\\Files_Trashbin\\Helper' => $baseDir . '/../lib/Helper.php',
'OCA\\Files_Trashbin\\Hooks' => $baseDir . '/../lib/Hooks.php',
'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => $baseDir . '/../lib/Listeners/LoadAdditionalScripts.php',
'OCA\\Files_Trashbin\\Listeners\\SyncLivePhotosListener' => $baseDir . '/../lib/Listeners/SyncLivePhotosListener.php',
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => $baseDir . '/../lib/Migration/Version1010Date20200630192639.php',
'OCA\\Files_Trashbin\\Sabre\\AbstractTrash' => $baseDir . '/../lib/Sabre/AbstractTrash.php',
'OCA\\Files_Trashbin\\Sabre\\AbstractTrashFile' => $baseDir . '/../lib/Sabre/AbstractTrashFile.php',

@ -39,6 +39,7 @@ class ComposerStaticInitFiles_Trashbin
'OCA\\Files_Trashbin\\Helper' => __DIR__ . '/..' . '/../lib/Helper.php',
'OCA\\Files_Trashbin\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php',
'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScripts.php',
'OCA\\Files_Trashbin\\Listeners\\SyncLivePhotosListener' => __DIR__ . '/..' . '/../lib/Listeners/SyncLivePhotosListener.php',
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630192639.php',
'OCA\\Files_Trashbin\\Sabre\\AbstractTrash' => __DIR__ . '/..' . '/../lib/Sabre/AbstractTrash.php',
'OCA\\Files_Trashbin\\Sabre\\AbstractTrashFile' => __DIR__ . '/..' . '/../lib/Sabre/AbstractTrashFile.php',

@ -28,8 +28,10 @@ namespace OCA\Files_Trashbin\AppInfo;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files_Trashbin\Capabilities;
use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent;
use OCA\Files_Trashbin\Expiration;
use OCA\Files_Trashbin\Listeners\LoadAdditionalScripts;
use OCA\Files_Trashbin\Listeners\SyncLivePhotosListener;
use OCA\Files_Trashbin\Trash\ITrashManager;
use OCA\Files_Trashbin\Trash\TrashManager;
use OCA\Files_Trashbin\UserMigration\TrashbinMigrator;
@ -62,6 +64,8 @@ class Application extends App implements IBootstrap {
LoadAdditionalScriptsEvent::class,
LoadAdditionalScripts::class
);
$context->registerEventListener(BeforeNodeRestoredEvent::class, SyncLivePhotosListener::class);
}
public function boot(IBootContext $context): void {

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Files_Trashbin\Listeners;
use OCA\Files\Service\LivePhotosService;
use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent;
use OCA\Files_Trashbin\Trash\ITrashItem;
use OCA\Files_Trashbin\Trash\ITrashManager;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Folder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IUserSession;
/**
* @template-implements IEventListener<BeforeNodeRestoredEvent>
*/
class SyncLivePhotosListener implements IEventListener {
/** @var Array<int, bool> */
private array $pendingRestores = [];
public function __construct(
private ?IUserSession $userSession,
private ITrashManager $trashManager,
private LivePhotosService $livePhotosService,
) {
}
public function handle(Event $event): void {
if ($this->userSession === null) {
return;
}
/** @var BeforeNodeRestoredEvent $event */
$peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getSource()->getId());
if ($peerFileId === null) {
return; // Not a live photo.
}
// Check the user's trashbin.
$user = $this->userSession->getUser();
if ($user === null) {
return;
}
$peerFile = $this->trashManager->getTrashNodeById($user, $peerFileId);
if ($peerFile === null) {
return; // Peer file not found.
}
$this->handleRestore($event, $peerFile);
}
/**
* During restore event, we trigger another recursive restore on the peer file.
* Restore operations on the .mov file directly are currently blocked.
* The event listener being singleton, we can store the current state
* of pending restores inside the 'pendingRestores' property,
* to prevent infinite recursivity.
*/
private function handleRestore(BeforeNodeRestoredEvent $event, Node $peerFile): void {
$sourceFile = $event->getSource();
if ($sourceFile->getMimetype() === 'video/quicktime') {
if (isset($this->pendingRestores[$peerFile->getId()])) {
unset($this->pendingRestores[$peerFile->getId()]);
return;
} else {
$event->abortOperation(new NotPermittedException("Cannot restore the video part of a live photo"));
}
} else {
$user = $this->userSession?->getUser();
if ($user === null) {
return;
}
$peerTrashItem = $this->trashManager->getTrashNodeById($user, $peerFile->getId());
// Peer file is not in the bin, no need to restore it.
if ($peerTrashItem === null) {
return;
}
$trashRoot = $this->trashManager->listTrashRoot($user);
$trashItem = $this->getTrashItem($trashRoot, $peerFile->getInternalPath());
if ($trashItem === null) {
$event->abortOperation(new NotFoundException("Couldn't find peer file in trashbin"));
}
$this->pendingRestores[$sourceFile->getId()] = true;
try {
$this->trashManager->restoreItem($trashItem);
} catch (\Throwable $ex) {
$event->abortOperation($ex);
}
}
}
/**
* There is currently no method to restore a file based on its fileId or path.
* So we have to manually find a ITrashItem from the trash item list.
* TODO: This should be replaced by a proper method in the TrashManager.
*/
private function getTrashItem(array $trashFolder, string $path): ?ITrashItem {
foreach($trashFolder as $trashItem) {
if (str_starts_with($path, "files_trashbin/files".$trashItem->getTrashPath())) {
if ($path === "files_trashbin/files".$trashItem->getTrashPath()) {
return $trashItem;
}
if ($trashItem instanceof Folder) {
$node = $this->getTrashItem($trashItem->getDirectoryListing(), $path);
if ($node !== null) {
return $node;
}
}
}
}
return null;
}
}

@ -32,6 +32,10 @@ export default defineConfig({
// faster video processing
videoCompression: false,
// Prevent elements to be scrolled under a top bar during actions (click, clear, type, etc). Default is 'top'.
// https://github.com/cypress-io/cypress/issues/871
scrollBehavior: 'center',
// Visual regression testing
env: {
failSilently: false,

@ -132,6 +132,8 @@ export const configureNextcloud = async function() {
await runExec(container, ['php', 'occ', 'config:system:set', 'default_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'force_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'enforce_theme', '--value', 'light'], true)
// Speed up test and make them less flaky. If a cron execution is needed, it can be triggered manually.
await runExec(container, ['php', 'occ', 'background:cron'], true)
console.log('└─ Nextcloud is now ready to use 🎉')
}

@ -20,17 +20,31 @@
*
*/
export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`)
export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`)
export const getActionsForFileId = (fileid: number) => getRowForFileId(fileid).find('[data-cy-files-list-row-actions]')
export const getActionsForFile = (filename: string) => getRowForFile(filename).find('[data-cy-files-list-row-actions]')
export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).find('button[aria-label="Actions"]')
export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).find('button[aria-label="Actions"]')
export const triggerActionForFileId = (fileid: number, actionId: string) => {
getActionButtonForFileId(fileid).click()
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click()
}
export const triggerActionForFile = (filename: string, actionId: string) => {
getActionButtonForFile(filename).click()
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click()
}
export const triggerInlineActionForFileId = (fileid: number, actionId: string) => {
getActionsForFileId(fileid).find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click()
}
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) => {
getRowForFile(fileName).should('be.visible')
triggerActionForFile(fileName, 'move-copy')
@ -85,6 +99,23 @@ export const copyFile = (fileName: string, dirName: string) => {
})
}
export const renameFile = (fileName: string, newFileName: string) => {
getRowForFile(fileName)
triggerActionForFile(fileName, 'rename')
// intercept the move so we can wait for it
cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile')
getRowForFile(fileName).find('[data-cy-files-list-row-name] input').clear()
getRowForFile(fileName).find('[data-cy-files-list-row-name] input').type(`${newFileName}{enter}`)
cy.wait('@moveFile')
}
export const navigateToFolder = (folderName: string) => {
getRowForFile(folderName).should('be.visible').find('[data-cy-files-list-row-name-link]').click()
}
export const closeSidebar = () => {
cy.get('[cy-data-sidebar] .app-sidebar__close').click()
}

@ -0,0 +1,215 @@
/**
* @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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')
}
/**
*
* @param user
* @param fileName
* @param domain
* @param requesttoken
* @param metadata
*/
function setMetadata(user: User, fileName: string, domain: string, requesttoken: string, metadata: object) {
cy.request({
method: 'PROPPATCH',
url: `http://${domain}/remote.php/dav/files/${user.userId}/${fileName}`,
auth: { user: user.userId, pass: user.password },
headers: {
requesttoken,
},
body: `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<d:set>
<d:prop>
${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')}
</d:prop>
</d:set>
</d:propertyupdate>`,
})
}
describe('Files: Live photos', { testIsolation: true }, () => {
let currentUser: User
let randomFileName: string
let jpgFileId: number
let movFileId: number
let hostname: string
let requesttoken: string
before(() => {
cy.createRandomUser().then((user) => {
currentUser = user
cy.login(currentUser)
cy.visit('/apps/files')
})
cy.url().then(url => { hostname = new URL(url).hostname })
})
beforeEach(() => {
randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
cy.uploadContent(currentUser, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${randomFileName}.jpg`)
.then(response => { jpgFileId = parseInt(response.headers['oc-fileid']) })
cy.uploadContent(currentUser, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${randomFileName}.mov`)
.then(response => { movFileId = parseInt(response.headers['oc-fileid']) })
cy.login(currentUser)
cy.visit('/apps/files')
cy.get('head').invoke('attr', 'data-requesttoken').then(_requesttoken => { requesttoken = _requesttoken as string })
cy.then(() => {
setMetadata(currentUser, `${randomFileName}.jpg`, hostname, requesttoken, { 'nc:metadata-files-live-photo': movFileId })
setMetadata(currentUser, `${randomFileName}.mov`, hostname, requesttoken, { 'nc:metadata-files-live-photo': jpgFileId })
})
cy.then(() => {
cy.visit(`/apps/files/files/${jpgFileId}`) // Refresh and scroll to the .jpg file.
closeSidebar()
})
})
it('Only renders the .jpg file', () => {
getRowForFileId(jpgFileId).should('have.length', 1)
getRowForFileId(movFileId).should('have.length', 0)
})
context("'Show hidden files' is enabled", () => {
before(() => {
cy.login(currentUser)
cy.visit('/apps/files')
cy.get('[data-cy-files-navigation-settings-button]').click()
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('[data-cy-files-settings-setting="show_hidden"] input').check({ force: true })
})
it("Shows both files when 'Show hidden files' is enabled", () => {
getRowForFileId(jpgFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.jpg`)
getRowForFileId(movFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.mov`)
})
it('Copies both files when copying the .jpg', () => {
copyFile(`${randomFileName}.jpg`, '.')
refreshView('All files')
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
})
it('Copies both files when copying the .mov', () => {
copyFile(`${randomFileName}.mov`, '.')
refreshView('All files')
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
})
it('Moves files when moving the .jpg', () => {
renameFile(`${randomFileName}.jpg`, `${randomFileName}_moved.jpg`)
refreshView('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`)
})
it('Moves files when moving the .mov', () => {
renameFile(`${randomFileName}.mov`, `${randomFileName}_moved.mov`)
refreshView('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`)
})
it('Deletes files when deleting the .jpg', () => {
triggerActionForFile(`${randomFileName}.jpg`, 'delete')
refreshView('All files')
getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
cy.visit('/apps/files/trashbin')
getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.jpg\\.d[0-9]+$`))
getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.mov\\.d[0-9]+$`))
})
it('Block deletion when deleting the .mov', () => {
triggerActionForFile(`${randomFileName}.mov`, 'delete')
refreshView('All files')
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
cy.visit('/apps/files/trashbin')
getRowForFileId(jpgFileId).should('have.length', 0)
getRowForFileId(movFileId).should('have.length', 0)
})
it('Restores files when restoring the .jpg', () => {
triggerActionForFile(`${randomFileName}.jpg`, 'delete')
cy.visit('/apps/files/trashbin')
triggerInlineActionForFileId(jpgFileId, 'restore')
refreshView('Deleted files')
getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
cy.visit('/apps/files')
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
})
it('Blocks restoration when restoring the .mov', () => {
triggerActionForFile(`${randomFileName}.jpg`, 'delete')
cy.visit('/apps/files/trashbin')
triggerInlineActionForFileId(movFileId, 'restore')
refreshView('Deleted files')
getRowForFileId(jpgFileId).should('have.length', 1)
getRowForFileId(movFileId).should('have.length', 1)
cy.visit('/apps/files')
getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
})
})
})

@ -58,8 +58,10 @@ export function updateShare(fileName: string, index: number, shareSettings: Part
if (shareSettings.download !== undefined) {
cy.get('[data-cy-files-sharing-share-permissions-checkbox="download"]').find('input').as('downloadCheckbox')
if (shareSettings.download) {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@downloadCheckbox').check({ force: true, scrollBehavior: 'nearest' })
} else {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@downloadCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
}
}
@ -67,8 +69,10 @@ export function updateShare(fileName: string, index: number, shareSettings: Part
if (shareSettings.read !== undefined) {
cy.get('[data-cy-files-sharing-share-permissions-checkbox="read"]').find('input').as('readCheckbox')
if (shareSettings.read) {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@readCheckbox').check({ force: true, scrollBehavior: 'nearest' })
} else {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@readCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
}
}
@ -76,8 +80,10 @@ export function updateShare(fileName: string, index: number, shareSettings: Part
if (shareSettings.update !== undefined) {
cy.get('[data-cy-files-sharing-share-permissions-checkbox="update"]').find('input').as('updateCheckbox')
if (shareSettings.update) {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@updateCheckbox').check({ force: true, scrollBehavior: 'nearest' })
} else {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@updateCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
}
}
@ -85,8 +91,10 @@ export function updateShare(fileName: string, index: number, shareSettings: Part
if (shareSettings.delete !== undefined) {
cy.get('[data-cy-files-sharing-share-permissions-checkbox="delete"]').find('input').as('deleteCheckbox')
if (shareSettings.delete) {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@deleteCheckbox').check({ force: true, scrollBehavior: 'nearest' })
} else {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@deleteCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
}
}

@ -113,7 +113,7 @@ describe('Versions restoration', () => {
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
Destination: 'https://nextcloud_server1.test/remote.php/dav/versions/admin/restore/target',
Destination: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/restore/target`,
},
url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false,

@ -49,7 +49,7 @@ declare global {
* Upload a raw content to a given user storage.
* **Warning**: Using this function will reset the previous session
*/
uploadContent(user: User, content: Blob, mimeType: string, target: string, mtime?: number): Cypress.Chainable<void>,
uploadContent(user: User, content: Blob, mimeType: string, target: string, mtime?: number): Cypress.Chainable<AxiosResponse>,
/**
* Create a new directory
@ -156,7 +156,7 @@ Cypress.Commands.add('setFileAsFavorite', (user: User, target: string, favorite
<oc:favorite>${favorite ? 1 : 0}</oc:favorite>
</d:prop>
</d:set>
</d:propertyupdate>`
</d:propertyupdate>`,
})
cy.log(`Created directory ${target}`, response)
} catch (error) {
@ -198,36 +198,36 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => {
* @param {string} mimeType e.g. image/png
* @param {string} target the target of the file relative to the user root
*/
Cypress.Commands.add('uploadContent', (user, blob, mimeType, target, mtime = undefined) => {
// eslint-disable-next-line cypress/unsafe-to-chain-command
Cypress.Commands.add('uploadContent', (user: User, blob: Blob, mimeType: string, target: string, mtime?: number) => {
cy.clearCookies()
.then(async () => {
const fileName = basename(target)
return cy.then(async () => {
const fileName = basename(target)
// Process paths
const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
const filePath = target.split('/').map(encodeURIComponent).join('/')
try {
const file = new File([blob], fileName, { type: mimeType })
const response = await axios({
url: `${rootPath}${filePath}`,
method: 'PUT',
data: file,
headers: {
'Content-Type': mimeType,
'X-OC-MTime': mtime ? `${mtime}` : undefined,
},
auth: {
username: user.userId,
password: user.password,
},
})
cy.log(`Uploaded content as ${fileName}`, response)
} catch (error) {
cy.log('error', error)
throw new Error('Unable to process fixture')
}
})
// Process paths
const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
const filePath = target.split('/').map(encodeURIComponent).join('/')
try {
const file = new File([blob], fileName, { type: mimeType })
const response = await axios({
url: `${rootPath}${filePath}`,
method: 'PUT',
data: file,
headers: {
'Content-Type': mimeType,
'X-OC-MTime': mtime ? `${mtime}` : undefined,
},
auth: {
username: user.userId,
password: user.password,
},
})
cy.log(`Uploaded content as ${fileName}`, response)
return response
} catch (error) {
cy.log('error', error)
throw new Error('Unable to process fixture')
}
})
})
/**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save