From 68bc6b797bebd0fe1301692abde8eaeab217c47f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:00:27 -0300 Subject: [PATCH 1/7] feat: add custom path support for envelope creation Allow envelopes to be created at user-specified absolute paths. Add getUserRootFolder() and getOrCreateFolderByAbsolutePath() methods to FolderService for path-based folder management with validation. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/EnvelopeService.php | 22 ++++++--- lib/Service/FolderService.php | 59 +++++++++++++++++++++++ src/actions/openInLibreSignAction.js | 70 +++++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/lib/Service/EnvelopeService.php b/lib/Service/EnvelopeService.php index c9cbd1340a..c2fab5305a 100644 --- a/lib/Service/EnvelopeService.php +++ b/lib/Service/EnvelopeService.php @@ -48,14 +48,22 @@ public function validateEnvelopeConstraints(int $fileCount): void { } } - public function createEnvelope(string $name, string $userId, int $filesCount = 0): FileEntity { + public function createEnvelope( + string $name, + string $userId, + int $filesCount = 0, + ?string $path = null, + ): FileEntity { $this->folderService->setUserId($userId); - $parentFolder = $this->folderService->getFolder(); - $uuid = UUIDUtil::getUUID(); - $folderName = $name . '_' . $uuid; - $envelopeFolder = $parentFolder->newFolder($folderName); + if ($path) { + $envelopeFolder = $this->folderService->getOrCreateFolderByAbsolutePath($path); + } else { + $parentFolder = $this->folderService->getFolder(); + $folderName = $name . '_' . $uuid; + $envelopeFolder = $parentFolder->newFolder($folderName); + } $envelope = new FileEntity(); $envelope->setNodeId($envelopeFolder->getId()); @@ -114,9 +122,9 @@ public function getEnvelopeFolder(FileEntity $envelope): \OCP\Files\Folder { } $this->folderService->setUserId($userId); - $userFolder = $this->folderService->getFolder(); + $userRootFolder = $this->folderService->getUserRootFolder(); - $envelopeFolderNode = $userFolder->getFirstNodeById($envelope->getNodeId()); + $envelopeFolderNode = $userRootFolder->getFirstNodeById($envelope->getNodeId()); if (!$envelopeFolderNode instanceof \OCP\Files\Folder) { throw new LibresignException('Envelope folder not found'); } diff --git a/lib/Service/FolderService.php b/lib/Service/FolderService.php index 2d97f6cd7f..bb24543309 100644 --- a/lib/Service/FolderService.php +++ b/lib/Service/FolderService.php @@ -45,6 +45,19 @@ public function getUserId(): ?string { return $this->userId; } + /** + * Get the user's root folder (full home), not the LibreSign container. + * + * @throws LibresignException + */ + public function getUserRootFolder(): Folder { + if (!$this->userId) { + throw new LibresignException('Invalid user to resolve folder'); + } + + return $this->root->getUserFolder($this->userId); + } + /** * Get folder for user and creates it if non-existent * @@ -200,4 +213,50 @@ public function getFileByPath(string $path): Node { throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404); } } + + /** + * Ensure a folder exists at a given absolute user path, creating missing segments. + * If the final folder already exists, it must be empty. + * + * @throws LibresignException + */ + public function getOrCreateFolderByAbsolutePath(string $path): Folder { + if (!$this->userId) { + throw new LibresignException('Invalid user to create envelope folder'); + } + + $cleanPath = ltrim($path, '/'); + $userFolder = $this->root->getUserFolder($this->userId); + + if ($cleanPath === '') { + return $userFolder; + } + + $segments = array_filter(explode('/', $cleanPath), static fn (string $segment) => $segment !== ''); + $folder = $userFolder; + $isLastSegment = false; + + foreach ($segments as $index => $segment) { + $isLastSegment = ($index === count($segments) - 1); + + try { + $node = $folder->get($segment); + if (!$node instanceof Folder) { + throw new LibresignException('Invalid folder path'); + } + $folder = $node; + + if ($isLastSegment) { + $contents = $folder->getDirectoryListing(); + if (count($contents) > 0) { + throw new LibresignException($this->l10n->t('Folder already exists and is not empty: %s', [$path])); + } + } + } catch (NotFoundException) { + $folder = $folder->newFolder($segment); + } + } + + return $folder; + } } diff --git a/src/actions/openInLibreSignAction.js b/src/actions/openInLibreSignAction.js index e9b0bea3ac..5579c1248c 100644 --- a/src/actions/openInLibreSignAction.js +++ b/src/actions/openInLibreSignAction.js @@ -3,12 +3,43 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { registerFileAction, FileAction } from '@nextcloud/files' +import { getCapabilities } from '@nextcloud/capabilities' import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' +import { showError } from '@nextcloud/dialogs' +import { spawnDialog } from '@nextcloud/vue/functions/dialog' +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import EditNameDialog from '../Components/Common/EditNameDialog.vue' // eslint-disable-next-line import/no-unresolved import SvgIcon from '../../img/app-dark.svg?raw' -import logger from '../logger.js' + +/** + * Prompts user for envelope name via dialog + */ +function promptEnvelopeName() { + return new Promise((resolve) => { + const propsData = { + title: t('libresign', 'Envelope name'), + label: t('libresign', 'Enter a name for the envelope'), + placeholder: t('libresign', 'Envelope name'), + } + + spawnDialog( + { + ...EditNameDialog, + mounted() { + EditNameDialog.mounted?.call(this) + this.$on('close', (value) => { + resolve(value) + }) + }, + }, + propsData, + ) + }) +} export const action = new FileAction({ id: 'open-in-libresign', @@ -33,6 +64,43 @@ export const action = new FileAction({ } }, + /** + * Multiple files: create envelope (if > 1) or delegate to exec (if = 1) + */ + async execBatch({ nodes }) { + if (nodes.length === 1) { + await this.exec({ nodes }) + return [null] + } + + const envelopeName = await promptEnvelopeName() + + if (!envelopeName) { + return new Array(nodes.length).fill(null) + } + + return axios.post(generateOcsUrl('/apps/libresign/api/v1/file'), { + files: nodes.map(node => ({ fileId: node.fileid })), + name: envelopeName, + }).then((response) => { + const envelopeData = response.data?.ocs?.data + + window.OCA.Libresign.pendingEnvelope = envelopeData + + window.OCA.Files.Sidebar.close() + + window.OCA.Files.Sidebar.setActiveTab('libresign') + const firstNode = nodes[0] + window.OCA.Files.Sidebar.open(firstNode.path) + + return new Array(nodes.length).fill(null) + }).catch((error) => { + console.error('[LibreSign] API error:', error) + showError(error.response?.data?.ocs?.data?.message) + return new Array(nodes.length).fill(null) + }) + }, + order: -1000, }) From 69d7c5a615719c98d897e76e7b9de312fdb573a3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:35:02 -0300 Subject: [PATCH 2/7] refactor: organize envelope services under dedicated namespace Move EnvelopeService and EnvelopeFileRelocator to Service/Envelope/ namespace for better code organization. Extract file relocation logic from RequestSignatureService into EnvelopeFileRelocator to improve separation of concerns and testability. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Capabilities.php | 2 +- .../Envelope/EnvelopeFileRelocator.php | 45 ++++++ lib/Service/Envelope/EnvelopeService.php | 139 ++++++++++++++++++ lib/Service/FileService.php | 1 + lib/Service/RequestSignatureService.php | 30 +++- 5 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 lib/Service/Envelope/EnvelopeFileRelocator.php create mode 100644 lib/Service/Envelope/EnvelopeService.php diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 7a06e705d0..70580b4318 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -8,7 +8,7 @@ namespace OCA\Libresign; -use OCA\Libresign\Service\EnvelopeService; +use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; use OCP\App\IAppManager; diff --git a/lib/Service/Envelope/EnvelopeFileRelocator.php b/lib/Service/Envelope/EnvelopeFileRelocator.php new file mode 100644 index 0000000000..0e94d29b00 --- /dev/null +++ b/lib/Service/Envelope/EnvelopeFileRelocator.php @@ -0,0 +1,45 @@ +folderService->setUserId($userManager->getUID()); + $userRootFolder = $this->folderService->getUserRootFolder(); + $envelopeFolder = $userRootFolder->getFirstNodeById($envelopeFolderId); + + if (!$envelopeFolder instanceof \OCP\Files\Folder) { + throw new LibresignException('Envelope folder not found'); + } + + if ($this->isNodeInsideFolder($sourceNode, $envelopeFolder)) { + return $sourceNode; + } + + if (!$sourceNode instanceof \OCP\Files\File) { + throw new LibresignException('Invalid file type for envelope'); + } + + return $envelopeFolder->newFile($sourceNode->getName(), $sourceNode->getContent()); + } + + private function isNodeInsideFolder(Node $node, \OCP\Files\Folder $folder): bool { + return str_starts_with($node->getPath(), $folder->getPath() . '/'); + } +} diff --git a/lib/Service/Envelope/EnvelopeService.php b/lib/Service/Envelope/EnvelopeService.php new file mode 100644 index 0000000000..8871030e69 --- /dev/null +++ b/lib/Service/Envelope/EnvelopeService.php @@ -0,0 +1,139 @@ +appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true); + } + + /** + * @throws LibresignException + */ + public function validateEnvelopeConstraints(int $fileCount): void { + if (!$this->isEnabled()) { + throw new LibresignException($this->l10n->t('Envelope feature is disabled')); + } + + $maxFiles = $this->getMaxFilesPerEnvelope(); + if ($fileCount > $maxFiles) { + throw new LibresignException( + $this->l10n->t('Maximum number of files per envelope (%s) exceeded', [$maxFiles]) + ); + } + } + + public function createEnvelope( + string $name, + string $userId, + int $filesCount = 0, + ?string $path = null, + ): FileEntity { + $this->folderService->setUserId($userId); + + $uuid = UUIDUtil::getUUID(); + if ($path) { + $envelopeFolder = $this->folderService->getOrCreateFolderByAbsolutePath($path); + } else { + $parentFolder = $this->folderService->getFolder(); + $folderName = $name . '_' . $uuid; + $envelopeFolder = $parentFolder->newFolder($folderName); + } + + $envelope = new FileEntity(); + $envelope->setNodeId($envelopeFolder->getId()); + $envelope->setNodeTypeEnum(NodeType::ENVELOPE); + $envelope->setName($name); + $envelope->setUuid($uuid); + $envelope->setCreatedAt(new DateTime()); + $envelope->setStatus(FileEntity::STATUS_DRAFT); + + $envelope->setMetadata(['filesCount' => $filesCount]); + + if ($userId) { + $envelope->setUserId($userId); + } + + return $this->fileMapper->insert($envelope); + } + + public function addFileToEnvelope(int $envelopeId, FileEntity $file): FileEntity { + $envelope = $this->fileMapper->getById($envelopeId); + + if (!$envelope->isEnvelope()) { + throw new LibresignException($this->l10n->t('The specified ID is not an envelope')); + } + + if ($envelope->getStatus() > FileEntity::STATUS_DRAFT) { + throw new LibresignException($this->l10n->t('Cannot add files to an envelope that is already in signing process')); + } + + $maxFiles = $this->getMaxFilesPerEnvelope(); + $currentCount = $this->fileMapper->countChildrenFiles($envelopeId); + if ($currentCount >= $maxFiles) { + throw new LibresignException( + $this->l10n->t('Maximum number of files per envelope (%s) exceeded', [$maxFiles]) + ); + } + + $file->setParentFileId($envelopeId); + $file->setNodeTypeEnum(NodeType::FILE); + + return $this->fileMapper->update($file); + } + + public function getEnvelopeByFileId(int $fileId): ?FileEntity { + try { + return $this->fileMapper->getParentEnvelope($fileId); + } catch (DoesNotExistException) { + return null; + } + } + + public function getEnvelopeFolder(FileEntity $envelope): \OCP\Files\Folder { + $userId = $envelope->getUserId(); + if (!$userId) { + throw new LibresignException('Envelope does not have a user'); + } + + $this->folderService->setUserId($userId); + $userRootFolder = $this->folderService->getUserRootFolder(); + + $envelopeFolderNode = $userRootFolder->getFirstNodeById($envelope->getNodeId()); + if (!$envelopeFolderNode instanceof \OCP\Files\Folder) { + throw new LibresignException('Envelope folder not found'); + } + + return $envelopeFolderNode; + } + + private function getMaxFilesPerEnvelope(): int { + return $this->appConfig->getValueInt(Application::APP_ID, 'envelope_max_files', 50); + } +} diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 01a2bdf415..be8211173a 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -21,6 +21,7 @@ use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\ResponseDefinitions; +use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\File\CertificateChainService; use OCA\Libresign\Service\File\EnvelopeAssembler; use OCA\Libresign\Service\File\EnvelopeProgressService; diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 78f9e49653..86ba071b4b 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -21,6 +21,8 @@ use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; +use OCA\Libresign\Service\Envelope\EnvelopeFileRelocator; +use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IMimeTypeDetector; @@ -58,6 +60,7 @@ public function __construct( protected FileStatusService $fileStatusService, protected DocMdpConfigService $docMdpConfigService, protected EnvelopeService $envelopeService, + protected EnvelopeFileRelocator $envelopeFileRelocator, protected FileUploadHelper $uploadHelper, protected SignRequestService $signRequestService, ) { @@ -162,7 +165,8 @@ public function saveEnvelope(array $data): array { $createdNodes = []; try { - $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId, $filesCount); + $envelopePath = $data['settings']['path'] ?? null; + $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId, $filesCount, $envelopePath); $envelopeFolder = $this->envelopeService->getEnvelopeFolder($envelope); $envelopeSettings = array_merge($data['settings'] ?? [], [ @@ -191,20 +195,30 @@ public function saveEnvelope(array $data): array { private function processFileData(array $fileData, ?IUser $userManager, array $settings): Node { if (isset($fileData['uploadedFile'])) { - return $this->fileService->getNodeFromData([ + $sourceNode = $this->fileService->getNodeFromData([ 'userManager' => $userManager, 'name' => $fileData['name'] ?? '', 'uploadedFile' => $fileData['uploadedFile'], 'settings' => $settings, ]); + } else { + $sourceNode = $this->fileService->getNodeFromData([ + 'userManager' => $userManager, + 'name' => $fileData['name'] ?? '', + 'file' => $fileData, + 'settings' => $settings, + ]); } - return $this->fileService->getNodeFromData([ - 'userManager' => $userManager, - 'name' => $fileData['name'] ?? '', - 'file' => $fileData, - 'settings' => $settings, - ]); + if (isset($settings['envelopeFolderId'])) { + return $this->envelopeFileRelocator->ensureFileInEnvelopeFolder( + $sourceNode, + $settings['envelopeFolderId'], + $userManager, + ); + } + + return $sourceNode; } private function rollbackEnvelopeCreation(?FileEntity $envelope, array $files, array $createdNodes): void { From 57ee0df4a7144621f08e90b55a60f4c715d80758 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:35:32 -0300 Subject: [PATCH 3/7] test: add tests for FolderService path management Add tests for getUserRootFolder() and getOrCreateFolderByAbsolutePath() to validate folder creation, nested paths, and empty folder validation. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Service/FolderServiceTest.php | 96 ++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/php/Unit/Service/FolderServiceTest.php b/tests/php/Unit/Service/FolderServiceTest.php index af4bc8f3a6..ec4a827556 100644 --- a/tests/php/Unit/Service/FolderServiceTest.php +++ b/tests/php/Unit/Service/FolderServiceTest.php @@ -334,4 +334,100 @@ public function testGetFolderForFileCreatesNewFolderWhenNoEnvelopeId(): void { $this->assertInstanceOf(Folder::class, $result); } + + public function testGetUserRootFolderReturnsUserFolder(): void { + $mockUserFolder = $this->createMock(Folder::class); + $this->root->expects($this->once()) + ->method('getUserFolder') + ->with('171') + ->willReturn($mockUserFolder); + + $service = $this->getInstance('171'); + $result = $service->getUserRootFolder(); + + $this->assertSame($mockUserFolder, $result); + } + + #[DataProvider('providerGetOrCreateFolderByAbsolutePath')] + public function testGetOrCreateFolderByAbsolutePathCreatesNestedFolders( + string $path, + array $existingFolders, + array $expectedNewFolders, + ): void { + $mockUserFolder = $this->createMock(Folder::class); + $this->root->method('getUserFolder')->willReturn($mockUserFolder); + + $currentFolder = $mockUserFolder; + $segments = array_filter(explode('/', ltrim($path, '/'))); + + foreach ($segments as $index => $segment) { + if (in_array($segment, $existingFolders)) { + $existingFolder = $this->createMock(Folder::class); + $existingFolder->method('getDirectoryListing')->willReturn([]); + $currentFolder->method('get') + ->with($segment) + ->willReturn($existingFolder); + $currentFolder = $existingFolder; + } elseif (in_array($segment, $expectedNewFolders)) { + $currentFolder->method('get') + ->with($segment) + ->willThrowException(new \OCP\Files\NotFoundException()); + + $newFolder = $this->createMock(Folder::class); + $newFolder->method('getDirectoryListing')->willReturn([]); + $currentFolder->method('newFolder') + ->with($segment) + ->willReturn($newFolder); + $currentFolder = $newFolder; + } + } + + $service = $this->getInstance('171'); + $result = $service->getOrCreateFolderByAbsolutePath($path); + + $this->assertInstanceOf(Folder::class, $result); + } + + public static function providerGetOrCreateFolderByAbsolutePath(): array { + return [ + 'create single folder at root' => [ + '/Envelopes', + [], + ['Envelopes'], + ], + 'create nested folders' => [ + '/Documents/Legal/Contracts', + [], + ['Documents', 'Legal', 'Contracts'], + ], + 'use existing folder' => [ + '/Existing', + ['Existing'], + [], + ], + 'create inside existing folder' => [ + '/Documents/NewFolder', + ['Documents'], + ['NewFolder'], + ], + ]; + } + + public function testGetOrCreateFolderByAbsolutePathFailsWhenFolderNotEmpty(): void { + $mockUserFolder = $this->createMock(Folder::class); + $this->root->method('getUserFolder')->willReturn($mockUserFolder); + + $existingFolder = $this->createMock(Folder::class); + $existingFile = $this->createMock(\OCP\Files\File::class); + $existingFolder->method('getDirectoryListing')->willReturn([$existingFile]); + + $mockUserFolder->method('get') + ->with('NotEmpty') + ->willReturn($existingFolder); + + $service = $this->getInstance('171'); + + $this->expectException(\OCA\Libresign\Exception\LibresignException::class); + $service->getOrCreateFolderByAbsolutePath('/NotEmpty'); + } } From 592f14df2b954d3ac426295ef7f7136f60121871 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:36:03 -0300 Subject: [PATCH 4/7] test: add comprehensive tests for envelope creation with custom paths Add tests covering custom paths (root, nested, with spaces), default naming patterns, empty folder validation, and file count constraints using dataProviders for better test organization. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../php/Unit/Service/EnvelopeServiceTest.php | 140 +++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/tests/php/Unit/Service/EnvelopeServiceTest.php b/tests/php/Unit/Service/EnvelopeServiceTest.php index 9c3cf89490..0998424b47 100644 --- a/tests/php/Unit/Service/EnvelopeServiceTest.php +++ b/tests/php/Unit/Service/EnvelopeServiceTest.php @@ -13,12 +13,13 @@ use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Enum\NodeType; use OCA\Libresign\Exception\LibresignException; -use OCA\Libresign\Service\EnvelopeService; +use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\FolderService; use OCA\Libresign\Tests\Unit\TestCase; use OCP\Files\Folder; use OCP\IAppConfig; use OCP\IL10N; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; final class EnvelopeServiceTest extends TestCase { @@ -186,4 +187,141 @@ function ($folderName) use ($mockEnvelopeFolder, &$capturedFolderName) { $this->assertStringStartsWith('Contract_', $capturedFolderName); $this->assertStringContainsString($envelope->getUuid(), $capturedFolderName); } + + #[DataProvider('envelopeCreationProvider')] + public function testEnvelopeCreationWithCustomPathOrDefaultNaming( + string $name, + string $userId, + int $filesCount, + ?string $customPath, + bool $expectCustomPath, + int $expectedNodeId, + ): void { + $this->fileMapper->method('insert')->willReturnArgument(0); + + if ($expectCustomPath) { + $mockEnvelopeFolder = $this->createMock(Folder::class); + $mockEnvelopeFolder->method('getId')->willReturn($expectedNodeId); + + $this->folderService + ->expects($this->once()) + ->method('getOrCreateFolderByAbsolutePath') + ->with($customPath) + ->willReturn($mockEnvelopeFolder); + + $envelope = $this->service->createEnvelope($name, $userId, $filesCount, $customPath); + + $this->assertSame($expectedNodeId, $envelope->getNodeId()); + $this->assertSame($name, $envelope->getName()); + $this->assertSame($userId, $envelope->getUserId()); + $this->assertSame(['filesCount' => $filesCount], $envelope->getMetadata()); + } else { + $mockDefaultFolder = $this->createMock(Folder::class); + $mockEnvelopeFolder = $this->createMock(Folder::class); + $mockEnvelopeFolder->method('getId')->willReturn($expectedNodeId); + + $this->folderService + ->expects($this->once()) + ->method('getFolder') + ->willReturn($mockDefaultFolder); + + $capturedFolderName = ''; + $mockDefaultFolder->method('newFolder') + ->willReturnCallback(function ($folderName) use ($mockEnvelopeFolder, &$capturedFolderName) { + $capturedFolderName = $folderName; + return $mockEnvelopeFolder; + }); + + $envelope = $this->service->createEnvelope($name, $userId, $filesCount, $customPath); + + $this->assertStringStartsWith($name . '_', $capturedFolderName); + $this->assertStringContainsString($envelope->getUuid(), $capturedFolderName); + $this->assertSame($expectedNodeId, $envelope->getNodeId()); + $this->assertSame($name, $envelope->getName()); + } + + $this->assertTrue($envelope->isEnvelope()); + $this->assertSame(FileEntity::STATUS_DRAFT, $envelope->getStatus()); + } + + public static function envelopeCreationProvider(): array { + return [ + 'custom path - root level' => [ + 'name' => 'Root Envelope', + 'userId' => 'user1', + 'filesCount' => 2, + 'customPath' => '/EnvelopeAtRoot', + 'expectCustomPath' => true, + 'expectedNodeId' => 100, + ], + 'custom path - nested' => [ + 'name' => 'Legal Contract', + 'userId' => 'user2', + 'filesCount' => 5, + 'customPath' => '/Documents/Legal/Contracts/2026', + 'expectCustomPath' => true, + 'expectedNodeId' => 200, + ], + 'custom path - with spaces' => [ + 'name' => 'Important Files', + 'userId' => 'user3', + 'filesCount' => 3, + 'customPath' => '/My Documents/Important Files', + 'expectCustomPath' => true, + 'expectedNodeId' => 300, + ], + 'default path - no custom path provided' => [ + 'name' => 'Standard Envelope', + 'userId' => 'user4', + 'filesCount' => 1, + 'customPath' => null, + 'expectCustomPath' => false, + 'expectedNodeId' => 888, + ], + 'default path - single file' => [ + 'name' => 'Contract Package', + 'userId' => 'testuser', + 'filesCount' => 1, + 'customPath' => null, + 'expectCustomPath' => false, + 'expectedNodeId' => 999, + ], + ]; + } + + public function testEnvelopeCreationFailsWhenCustomPathNotEmpty(): void { + $this->expectException(LibresignException::class); + + $this->folderService + ->method('getOrCreateFolderByAbsolutePath') + ->willThrowException(new LibresignException('Folder not empty')); + + $this->service->createEnvelope('Test', 'user', 1, '/Documents/Existing'); + } + + #[DataProvider('envelopeConstraintsProvider')] + public function testValidateEnvelopeConstraints( + int $fileCount, + bool $shouldPass, + ): void { + if (!$shouldPass) { + $this->expectException(LibresignException::class); + } + + $this->service->validateEnvelopeConstraints($fileCount); + + if ($shouldPass) { + $this->assertTrue(true); + } + } + + public static function envelopeConstraintsProvider(): array { + return [ + 'valid - 1 file' => [1, true], + 'valid - 10 files' => [10, true], + 'valid - exactly max (50)' => [50, true], + 'invalid - exceeds max' => [51, false], + 'invalid - way over max' => [100, false], + ]; + } } From cb3b92f425924e330f0cac87d6ff95887c8f89b0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:36:37 -0300 Subject: [PATCH 5/7] test: add tests for EnvelopeFileRelocator Add tests for file relocation logic validating files are copied into envelope folders when outside, returned as-is when already inside, and proper error handling for invalid nodes. Update namespace references in related test files. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Service/EnvelopeFileRelocatorTest.php | 115 ++++++++++++++++++ tests/php/Unit/Service/FileServiceTest.php | 2 +- .../Service/RequestSignatureServiceTest.php | 6 +- 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 tests/php/Unit/Service/EnvelopeFileRelocatorTest.php diff --git a/tests/php/Unit/Service/EnvelopeFileRelocatorTest.php b/tests/php/Unit/Service/EnvelopeFileRelocatorTest.php new file mode 100644 index 0000000000..a1937464aa --- /dev/null +++ b/tests/php/Unit/Service/EnvelopeFileRelocatorTest.php @@ -0,0 +1,115 @@ +folderService = $this->createMock(FolderService::class); + $this->relocator = new EnvelopeFileRelocator($this->folderService); + } + + public function testReturnsOriginalWhenAlreadyInside(): void { + $sourceFile = $this->createMock(\OCP\Files\File::class); + $sourceFile->method('getPath')->willReturn('/user/files/Envelope/doc.pdf'); + + $envelopeFolder = $this->createMock(Folder::class); + $envelopeFolder->method('getPath')->willReturn('/user/files/Envelope'); + + $rootFolder = $this->createMock(Folder::class); + $rootFolder->method('getFirstNodeById')->with(10)->willReturn($envelopeFolder); + + $this->folderService->expects($this->once())->method('setUserId')->with('u1'); + $this->folderService->method('getUserRootFolder')->willReturn($rootFolder); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('u1'); + + $result = $this->relocator->ensureFileInEnvelopeFolder($sourceFile, 10, $user); + self::assertSame($sourceFile, $result); + } + + public function testCopiesFileWhenOutside(): void { + $sourceFile = $this->createMock(\OCP\Files\File::class); + $sourceFile->method('getPath')->willReturn('/user/files/Other/doc.pdf'); + $sourceFile->method('getName')->willReturn('doc.pdf'); + $sourceFile->method('getContent')->willReturn('content'); + + $copiedFile = $this->createMock(\OCP\Files\File::class); + + $envelopeFolder = $this->createMock(Folder::class); + $envelopeFolder->method('getPath')->willReturn('/user/files/Envelope'); + $envelopeFolder->expects($this->once()) + ->method('newFile') + ->with('doc.pdf', 'content') + ->willReturn($copiedFile); + + $rootFolder = $this->createMock(Folder::class); + $rootFolder->method('getFirstNodeById')->with(10)->willReturn($envelopeFolder); + + $this->folderService->expects($this->once())->method('setUserId')->with('u1'); + $this->folderService->method('getUserRootFolder')->willReturn($rootFolder); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('u1'); + + $result = $this->relocator->ensureFileInEnvelopeFolder($sourceFile, 10, $user); + self::assertSame($copiedFile, $result); + } + + public function testThrowsWhenEnvelopeFolderNotFound(): void { + $sourceFile = $this->createMock(\OCP\Files\File::class); + $sourceFile->method('getPath')->willReturn('/user/files/doc.pdf'); + + $rootFolder = $this->createMock(Folder::class); + $rootFolder->method('getFirstNodeById')->with(10)->willReturn($this->createMock(Node::class)); + + $this->folderService->method('setUserId'); + $this->folderService->method('getUserRootFolder')->willReturn($rootFolder); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('u1'); + + $this->expectException(LibresignException::class); + $this->expectExceptionMessage('Envelope folder not found'); + $this->relocator->ensureFileInEnvelopeFolder($sourceFile, 10, $user); + } + + public function testThrowsWhenSourceIsNotFile(): void { + $sourceNode = $this->createMock(Node::class); + $sourceNode->method('getPath')->willReturn('/user/files/Other'); + + $envelopeFolder = $this->createMock(Folder::class); + $envelopeFolder->method('getPath')->willReturn('/user/files/Envelope'); + + $rootFolder = $this->createMock(Folder::class); + $rootFolder->method('getFirstNodeById')->with(10)->willReturn($envelopeFolder); + + $this->folderService->method('setUserId'); + $this->folderService->method('getUserRootFolder')->willReturn($rootFolder); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('u1'); + + $this->expectException(LibresignException::class); + $this->expectExceptionMessage('Invalid file type for envelope'); + $this->relocator->ensureFileInEnvelopeFolder($sourceNode, 10, $user); + } +} diff --git a/tests/php/Unit/Service/FileServiceTest.php b/tests/php/Unit/Service/FileServiceTest.php index 967ba90be6..62fdf48b69 100644 --- a/tests/php/Unit/Service/FileServiceTest.php +++ b/tests/php/Unit/Service/FileServiceTest.php @@ -29,7 +29,7 @@ private function createFileService(array $overrides = []): FileService { \OCP\Files\IRootFolder::class, \Psr\Log\LoggerInterface::class, \OCP\IL10N::class, - \OCA\Libresign\Service\EnvelopeService::class, + \OCA\Libresign\Service\Envelope\EnvelopeService::class, \OCA\Libresign\Service\File\SignersLoader::class, \OCA\Libresign\Helper\FileUploadHelper::class, \OCA\Libresign\Service\File\EnvelopeAssembler::class, diff --git a/tests/php/Unit/Service/RequestSignatureServiceTest.php b/tests/php/Unit/Service/RequestSignatureServiceTest.php index aade86a603..98bd13c960 100644 --- a/tests/php/Unit/Service/RequestSignatureServiceTest.php +++ b/tests/php/Unit/Service/RequestSignatureServiceTest.php @@ -15,7 +15,8 @@ use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\DocMdpConfigService; -use OCA\Libresign\Service\EnvelopeService; +use OCA\Libresign\Service\Envelope\EnvelopeFileRelocator; +use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\FileElementService; use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\FileStatusService; @@ -63,6 +64,7 @@ final class RequestSignatureServiceTest extends \OCA\Libresign\Tests\Unit\TestCa private FileStatusService&MockObject $fileStatusService; private DocMdpConfigService&MockObject $docMdpConfigService; private EnvelopeService&MockObject $envelopeService; + private EnvelopeFileRelocator&MockObject $envelopeFileRelocator; private FileUploadHelper&MockObject $uploadHelper; private SignRequestService&MockObject $signRequestService; @@ -96,6 +98,7 @@ public function setUp(): void { $this->fileStatusService = $this->createMock(FileStatusService::class); $this->docMdpConfigService = $this->createMock(DocMdpConfigService::class); $this->envelopeService = $this->createMock(EnvelopeService::class); + $this->envelopeFileRelocator = $this->createMock(EnvelopeFileRelocator::class); $this->uploadHelper = $this->createMock(FileUploadHelper::class); $this->signRequestService = $this->createMock(SignRequestService::class); } @@ -124,6 +127,7 @@ private function getService(): RequestSignatureService { $this->fileStatusService, $this->docMdpConfigService, $this->envelopeService, + $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, ); From fa03a6fd8370a0994d8c3983e2f8b8a166d95670 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:37:29 -0300 Subject: [PATCH 6/7] feat: create envelope file relocator service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/EnvelopeFileRelocator.php | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 lib/Service/EnvelopeFileRelocator.php diff --git a/lib/Service/EnvelopeFileRelocator.php b/lib/Service/EnvelopeFileRelocator.php new file mode 100644 index 0000000000..1c94fbf79f --- /dev/null +++ b/lib/Service/EnvelopeFileRelocator.php @@ -0,0 +1,44 @@ +folderService->setUserId($userManager->getUID()); + $userRootFolder = $this->folderService->getUserRootFolder(); + $envelopeFolder = $userRootFolder->getFirstNodeById($envelopeFolderId); + + if (!$envelopeFolder instanceof \OCP\Files\Folder) { + throw new LibresignException('Envelope folder not found'); + } + + if ($this->isNodeInsideFolder($sourceNode, $envelopeFolder)) { + return $sourceNode; + } + + if (!$sourceNode instanceof \OCP\Files\File) { + throw new LibresignException('Invalid file type for envelope'); + } + + return $envelopeFolder->newFile($sourceNode->getName(), $sourceNode->getContent()); + } + + private function isNodeInsideFolder(Node $node, \OCP\Files\Folder $folder): bool { + return str_starts_with($node->getPath(), $folder->getPath() . '/'); + } +} From 1c5e64ad058ce2d79226678b0597e9a0a13145d2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:14:41 -0300 Subject: [PATCH 7/7] fix: change namespace Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/CapabilitiesTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/Unit/CapabilitiesTest.php b/tests/php/Unit/CapabilitiesTest.php index 21a96ca998..edb0f5722d 100644 --- a/tests/php/Unit/CapabilitiesTest.php +++ b/tests/php/Unit/CapabilitiesTest.php @@ -7,7 +7,7 @@ */ use OCA\Libresign\Capabilities; -use OCA\Libresign\Service\EnvelopeService; +use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; use OCP\App\IAppManager;