diff --git a/lib/UserMigration/MailAccountMigrator.php b/lib/UserMigration/MailAccountMigrator.php index e335cba1e2..c73df0e2d6 100644 --- a/lib/UserMigration/MailAccountMigrator.php +++ b/lib/UserMigration/MailAccountMigrator.php @@ -13,6 +13,7 @@ use OCA\Mail\UserMigration\Service\AccountMigrationService; use OCA\Mail\UserMigration\Service\AppConfigMigrationService; use OCA\Mail\UserMigration\Service\InternalAddressesMigrationService; +use OCA\Mail\UserMigration\Service\QuickActionsMigrationService; use OCA\Mail\UserMigration\Service\SMIMEMigrationService; use OCA\Mail\UserMigration\Service\TagsMigrationService; use OCA\Mail\UserMigration\Service\TextBlocksMigrationService; @@ -39,7 +40,8 @@ public function __construct( private readonly TrustedSendersMigrationService $trustedSendersMigrationService, private readonly TextBlocksMigrationService $textBlocksMigrationService, private readonly TagsMigrationService $tagsMigrationService, - private readonly SMIMEMigrationService $smimeMigrationService, + private readonly SMIMEMigrationService $sMimeMigrationService, + private readonly QuickActionsMigrationService $quickActionsMigrationService, ) { } @@ -58,8 +60,9 @@ public function export(IUser $user, $this->trustedSendersMigrationService->exportTrustedSenders($user, $exportDestination, $output); $this->textBlocksMigrationService->exportTextBlocks($user, $exportDestination, $output); $this->tagsMigrationService->exportTags($user, $exportDestination, $output); - $this->smimeMigrationService->exportCertificates($user, $exportDestination, $output); + $this->sMimeMigrationService->exportCertificates($user, $exportDestination, $output); $this->accountMigrationService->exportAccounts($user, $exportDestination, $output); + $this->quickActionsMigrationService->exportQuickActions($user, $exportDestination, $output); } /** @@ -81,9 +84,11 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $this->trustedSendersMigrationService->importTrustedSenders($user, $importSource, $output); $this->textBlocksMigrationService->importTextBlocks($user, $importSource, $output); $migratedTags = $this->tagsMigrationService->importTags($user, $importSource, $output); - $migratedCertificates = $this->smimeMigrationService->importCertificates($user, $importSource, $output); + $migratedCertificates = $this->sMimeMigrationService->importCertificates($user, $importSource, $output); $migratedAccountsAndMailboxes = $this->accountMigrationService->importAccounts($user, $importSource, $output, $migratedCertificates); + $this->quickActionsMigrationService->importQuickActions($user, $importSource, $output, + $migratedAccountsAndMailboxes['accounts'], $migratedAccountsAndMailboxes['mailboxes'], $migratedTags); $this->accountMigrationService->scheduleBackgroundJobs($user, $output); } diff --git a/lib/UserMigration/Service/QuickActionsMigrationService.php b/lib/UserMigration/Service/QuickActionsMigrationService.php new file mode 100644 index 0000000000..8fb206c6f7 --- /dev/null +++ b/lib/UserMigration/Service/QuickActionsMigrationService.php @@ -0,0 +1,201 @@ +writeln( + $this->l10n->t('Exporting quick actions for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + $quickActions = $this->quickActionsService->findAll($user->getUID()); + + try { + $exportDestination->addFileContents(self::QUICK_ACTIONS_FILE, + json_encode($quickActions, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export quick actions for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import all quick actions the user defined across + * their accounts. + * + * @throws \OCA\Mail\Exception\ServiceException + */ + public function importQuickActions(IUser $user, + IImportSource $importSource, + OutputInterface $output, + array $accountMapping, + array $mailboxMapping, + array $tagMapping): void { + $output->writeln( + $this->l10n->t('Importing quick actions for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + try { + $quickActionsFileContent = $importSource->getFileContents(self::QUICK_ACTIONS_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t('Quick actions for user %s not found. Continue...', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + try { + $quickActions = json_decode($quickActionsFileContent, true, flags: JSON_THROW_ON_ERROR); + $this->validateQuickActions($quickActions); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t('Quick actions configuration for user %s is invalid and will be skipped. Continue...', + [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + + foreach ($quickActions as $quickAction) { + $createdQuickAction = $this->quickActionsService->create($quickAction['name'], + $accountMapping[$quickAction['accountId']]); + + foreach ($quickAction['actionSteps'] as $actionStep) { + $this->quickActionsService->createActionStep($actionStep['name'], $actionStep['order'], + $createdQuickAction->getId(), $tagMapping[$actionStep['tagId']] ?? null, + $mailboxMapping[$actionStep['mailboxId']] ?? null); + } + } + } + + /** + * Validate the parsed quick actions to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateQuickActions(mixed $quickActions): void { + $quickActionsArrayIsValid = is_array($quickActions) && array_is_list($quickActions); + if (!$quickActionsArrayIsValid) { + throw new UserMigrationException('Invalid quick actions export structure'); + } + + foreach ($quickActions as $quickAction) { + $quickActionArrayIsValid = is_array($quickAction); + + $idIsValid = $quickActionArrayIsValid + && array_key_exists('id', $quickAction) + && is_int($quickAction['id']); + + $nameIsValid = $quickActionArrayIsValid + && array_key_exists('name', $quickAction) + && is_string($quickAction['name']); + + $orderIsValid = $quickActionArrayIsValid + && array_key_exists('accountId', $quickAction) + && is_int($quickAction['accountId']); + + $actionStepsArrayIsValid = $quickActionArrayIsValid + && array_key_exists('actionSteps', $quickAction) + && is_array($quickAction['actionSteps']) + && array_is_list($quickAction['actionSteps']) + && $this->validateQuickSteps($quickAction['actionSteps']); + + if ( + !$idIsValid + || !$nameIsValid + || !$orderIsValid + || !$actionStepsArrayIsValid + ) { + throw new UserMigrationException('Invalid quick action entry'); + } + } + } + + private function validateQuickSteps(mixed $quickSteps): bool { + $quickStepsArrayIsValid = true; + + foreach ($quickSteps as $actionStep) { + $actionStepArrayIsValid = is_array($actionStep); + + $idIsValid = $actionStepArrayIsValid + && array_key_exists('id', $actionStep) + && is_int($actionStep['id']); + + $nameIsValid = $actionStepArrayIsValid + && array_key_exists('name', $actionStep) + && is_string($actionStep['name']); + + $orderIsValid = $actionStepArrayIsValid + && array_key_exists('order', $actionStep) + && is_int($actionStep['order']); + + $actionIdIsValid = $actionStepArrayIsValid + && array_key_exists('actionId', $actionStep) + && is_int($actionStep['actionId']); + + $tagIdIsValid = $actionStepArrayIsValid + && array_key_exists('tagId', $actionStep) + && (is_int($actionStep['tagId']) || is_null($actionStep['tagId'])); + + $mailboxIdIsValid = $actionStepArrayIsValid + && array_key_exists('mailboxId', $actionStep) + && (is_int($actionStep['mailboxId']) || is_null($actionStep['mailboxId'])); + + $actionStepIsValid = $idIsValid + && $nameIsValid + && $orderIsValid + && $actionIdIsValid + && $tagIdIsValid + && $mailboxIdIsValid; + + $quickStepsArrayIsValid = $quickStepsArrayIsValid && $actionStepIsValid; + } + + return $quickStepsArrayIsValid; + } +} diff --git a/tests/Unit/UserMigration/Service/QuickActionsMigrationServiceTest.php b/tests/Unit/UserMigration/Service/QuickActionsMigrationServiceTest.php new file mode 100644 index 0000000000..a76e1ef4bf --- /dev/null +++ b/tests/Unit/UserMigration/Service/QuickActionsMigrationServiceTest.php @@ -0,0 +1,242 @@ +output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->serviceMock = $this->createServiceMock(QuickActionsMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + } + + public function testExportsMultipleQuickActions(): void { + $quickActions = $this->getQuickActions(); + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE, json_encode($quickActions)); + + $this->serviceMock->getParameter('quickActionsService') + ->method('findAll') + ->with(self::USER_ID) + ->willReturn($quickActions); + $this->migrationService->exportQuickActions($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoQuickActions(): void { + $this->serviceMock->getParameter('quickActionsService')->method('findAll')->with(self::USER_ID)->willReturn([]); + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE, json_encode([])); + + $this->migrationService->exportQuickActions($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleQuickActions(): void { + $quickActions = $this->getQuickActions(); + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode($quickActions)); + + $accountMapping = [5 => rand(100, 199)]; + $mailboxMapping = [5 => rand(200, 299)]; + $tagMapping = [2 => rand(300, 399)]; + + $createdIds = []; + $createCallCount = 0; + $expectedActions = $this->getQuickActions(); + $this->serviceMock + ->getParameter('quickActionsService')->expects(self::exactly(2))->method('create') + ->willReturnCallback(function (string $name, int $accountId) use ( + &$createCallCount, + &$createdIds, + $expectedActions, + $accountMapping + ): Actions { + $expected = $expectedActions[$createCallCount]; + self::assertSame($expected->getName(), $name); + self::assertSame($accountMapping[$expected->getAccountId()], $accountId); + $newActions = new Actions(); + $newActions->setId(rand(400, 499)); + $newActions->setName($name); + $newActions->setAccountId($accountId); + $createdIds[] = $newActions->getId(); + $createCallCount++; + return $newActions; + }); + + $expectedSteps = [ + ['name' => 'markAsRead', 'order' => 1, 'actionIndex' => 0, 'tagId' => null, 'mailboxId' => null], + ['name' => 'moveThread', 'order' => 2, 'actionIndex' => 0, 'tagId' => null, 'mailboxId' => $mailboxMapping[5]], + ['name' => 'markAsImportant', 'order' => 1, 'actionIndex' => 1, 'tagId' => null, 'mailboxId' => null], + ['name' => 'applyTag', 'order' => 2, 'actionIndex' => 1, 'tagId' => $tagMapping[2], 'mailboxId' => null], + ]; + $createStepCallCount = 0; + $this->serviceMock + ->getParameter('quickActionsService')->expects(self::exactly(4))->method('createActionStep') + ->willReturnCallback(function (string $name, int $order, int $actionId, ?int $tagId, ?int $mailboxId) use ( + & + $createStepCallCount, + &$createdIds, + $expectedSteps + ): ActionStep { + $expected = $expectedSteps[$createStepCallCount]; + self::assertSame($expected['name'], $name); + self::assertSame($expected['order'], $order); + self::assertSame($createdIds[$expected['actionIndex']], $actionId); + self::assertSame($expected['tagId'], $tagId); + self::assertSame($expected['mailboxId'], $mailboxId); + $newStep = new ActionStep(); + $newStep->setId(rand(500, 599)); + $createStepCallCount++; + return $newStep; + }); + + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, $accountMapping, + $mailboxMapping, $tagMapping); + } + + public function testImportNoQuickActions(): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode([])); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('create'); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('createActionStep'); + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, [], [], []); + } + + public static function provideFileContentsWithNoQuickActionsImported(): array { + return [ + 'empty list' => [json_encode([])], + 'invalid JSON' => ['this is not valid json {{{'], + 'JSON object instead of list' => [json_encode(['unexpected' => 'object'])], + ]; + } + + /** + * @dataProvider provideFileContentsWithNoQuickActionsImported + */ + public function testImportEmptyOrInvalidQuickActions(string $fileContents): void { + $this->importSource + ->expects(self::once())->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn($fileContents); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('create'); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('createActionStep'); + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, [], [], []); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource + ->expects(self::once()) + ->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willThrowException(new UserMigrationException()); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('create'); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('createActionStep'); + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, [], [], []); + } + + private function getFirstQuickAction(): Actions { + $firstAction = new Actions(); + + $firstAction->setId(self::FIRST_QUICK_STEP_ID); + $firstAction->setAccountId(self::QUICK_STEP_ACCOUNT_ID); + $firstAction->setName('First quick action'); + + $actionSteps = $this->getActionStepsForFirstQuickAction(); + $firstAction->setActionSteps($actionSteps); + + return $firstAction; + } + + private function getActionStepsForFirstQuickAction(): array { + $firstStep = new ActionStep(); + $firstStep->setId(1); + $firstStep->setActionId(self::QUICK_STEP_ACCOUNT_ID); + $firstStep->setName('markAsRead'); + $firstStep->setOrder(1); + + $secondStep = new ActionStep(); + $secondStep->setId(2); + $secondStep->setActionId(self::QUICK_STEP_ACCOUNT_ID); + $secondStep->setMailboxId(5); + $secondStep->setName('moveThread'); + $secondStep->setOrder(2); + + return [$firstStep, $secondStep]; + } + + private function getSecondQuickAction(): Actions { + $secondAction = new Actions(); + + $secondAction->setId(self::SECOND_QUICK_STEP_ID); + $secondAction->setAccountId(self::QUICK_STEP_ACCOUNT_ID); + $secondAction->setName('Second quick action'); + + $actionSteps = $this->getActionStepsForSecondQuickAction(); + $secondAction->setActionSteps($actionSteps); + + return $secondAction; + } + + private function getActionStepsForSecondQuickAction(): array { + $firstStep = new ActionStep(); + $firstStep->setId(3); + $firstStep->setActionId(self::SECOND_QUICK_STEP_ID); + $firstStep->setName('markAsImportant'); + $firstStep->setOrder(1); + + $secondStep = new ActionStep(); + $secondStep->setId(4); + $secondStep->setActionId(self::SECOND_QUICK_STEP_ID); + $secondStep->setTagId(2); + $secondStep->setName('applyTag'); + $secondStep->setOrder(2); + + return [$firstStep, $secondStep]; + } + + private function getQuickActions(): array { + return [$this->getFirstQuickAction(), $this->getSecondQuickAction()]; + } +}