diff --git a/lib/UserMigration/MailAccountMigrator.php b/lib/UserMigration/MailAccountMigrator.php index 417c695231..3a1e51eae6 100644 --- a/lib/UserMigration/MailAccountMigrator.php +++ b/lib/UserMigration/MailAccountMigrator.php @@ -14,6 +14,7 @@ use OCA\Mail\Exception\ServiceException; use OCA\Mail\UserMigration\Service\AccountMigrationService; use OCA\Mail\UserMigration\Service\AppConfigMigrationService; +use OCA\Mail\UserMigration\Service\TextBlocksMigrationService; use OCA\Mail\UserMigration\Service\TrustedSendersMigrationService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IL10N; @@ -35,6 +36,7 @@ public function __construct( private readonly AccountMigrationService $accountMigrationService, private readonly AppConfigMigrationService $appConfigMigrationService, private readonly TrustedSendersMigrationService $trustedSendersMigrationService, + private readonly TextBlocksMigrationService $textBlocksMigrationService, ) { } @@ -47,6 +49,7 @@ public function export(IUser $user, $this->appConfigMigrationService->exportAppConfiguration($user, $exportDestination, $output); $this->trustedSendersMigrationService->exportTrustedSenders($user, $exportDestination, $output); + $this->textBlocksMigrationService->exportTextBlocks($user, $exportDestination, $output); } #[\Override] @@ -56,6 +59,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $this->appConfigMigrationService->importAppConfiguration($user, $importSource, $output); $this->trustedSendersMigrationService->importTrustedSenders($user, $importSource, $output); + $this->textBlocksMigrationService->importTextBlocks($user, $importSource, $output); $this->accountMigrationService->scheduleBackgroundJobs($user, $output); } @@ -75,6 +79,7 @@ private function deleteExistingData(IUser $user, OutputInterface $output): void $this->appConfigMigrationService->deleteAppConfiguration($user, $output); $this->trustedSendersMigrationService->removeAllTrustedSenders($user, $output); + $this->textBlocksMigrationService->deleteAllTextBlocks($user, $output); $this->accountMigrationService->deleteAllAccounts($user, $output); } @@ -95,7 +100,7 @@ public function getDescription(): string { #[\Override] public function getVersion(): int { - return 01_00_00; + return 02_00_00; } #[\Override] diff --git a/lib/UserMigration/Service/TextBlocksMigrationService.php b/lib/UserMigration/Service/TextBlocksMigrationService.php new file mode 100644 index 0000000000..a4a2eab8ab --- /dev/null +++ b/lib/UserMigration/Service/TextBlocksMigrationService.php @@ -0,0 +1,128 @@ +writeln( + $this->l10n->t('Exporting text blocks for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + $textBlocks = $this->textBlockService->findAll($user->getUID()); + + try { + $exportDestination->addFileContents(self::TEXT_BLOCKS_FILE, json_encode($textBlocks, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export text blocks for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import all text blocks the user created itself on export. + * This does not include those shared with others. + * + * @throws UserMigrationException + */ + public function importTextBlocks(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln( + $this->l10n->t('Importing text blocks for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + try { + $textBlocksFileContent = $importSource->getFileContents(self::TEXT_BLOCKS_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t('Text blocks for user %s not found. Continue...', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + $textBlocks = json_decode($textBlocksFileContent, true); + $this->validateTextBlocks($textBlocks); + + foreach ($textBlocks as $textBlock) { + $output->writeln( + $this->l10n->t('Importing text block %s for user %s', [$textBlock['title'], $user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + $this->textBlockService->create($user->getUID(), $textBlock['title'], $textBlock['content']); + } + } + + public function deleteAllTextBlocks(IUser $user, OutputInterface $output): void { + $output->writeln( + $this->l10n->t('Delete existing text blocks for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + $this->textBlockService->deleteByUserId($user->getUID()); + } + + /** + * Validate the parsed text blocks to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateTextBlocks(mixed $textBlocks): void { + $textBlocksArrayIsValid = is_array($textBlocks) && array_is_list($textBlocks); + if (!$textBlocksArrayIsValid) { + throw new UserMigrationException('Invalid text blocks export structure'); + } + + foreach ($textBlocks as $textBlock) { + $textBlockArrayIsValid = is_array($textBlock); + + $titleIsValid = $textBlockArrayIsValid + && array_key_exists('title', $textBlock) + && is_string($textBlock['title']); + + $contentIsValid = $textBlockArrayIsValid + && array_key_exists('content', $textBlock) + && is_string($textBlock['content']); + + if (!$titleIsValid || !$contentIsValid) { + throw new UserMigrationException('Invalid text block entry'); + } + } + } +} diff --git a/tests/Unit/UserMigration/Service/TextBlocksMigrationServiceTest.php b/tests/Unit/UserMigration/Service/TextBlocksMigrationServiceTest.php new file mode 100644 index 0000000000..2dbb9359f4 --- /dev/null +++ b/tests/Unit/UserMigration/Service/TextBlocksMigrationServiceTest.php @@ -0,0 +1,140 @@ +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(TextBlocksMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + } + + public function testExportsMultipleTextBlocks(): void { + $textBlocksList = [$this->getLoremIpsum1(), $this->getIpsumLorem2()]; + $this->exportDestination->expects(self::once())->method('addFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE, json_encode($textBlocksList)); + + $this->serviceMock->getParameter('textBlockService')->method('findAll')->with(self::USER_ID)->willReturn($textBlocksList); + $this->migrationService->exportTextBlocks($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoneTextBlocks(): void { + $textBlocksList = []; + + $this->serviceMock->getParameter('textBlockService')->method('findAll')->with(self::USER_ID)->willReturn($textBlocksList); + $this->exportDestination->expects(self::once())->method('addFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE, json_encode($textBlocksList)); + + $this->migrationService->exportTextBlocks($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleTextBlocks(): void { + $textBlock1 = $this->getLoremIpsum1(); + $textBlock2 = $this->getIpsumLorem2(); + $textBlocksList = [$textBlock1, $textBlock2]; + $this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn(json_encode($textBlocksList)); + + $this->serviceMock->getParameter('textBlockService')->expects(self::exactly(2))->method('create')->with(self::USER_ID, self::callback(function ($title) use ($textBlock1, $textBlock2) { + return $title === $textBlock1->getTitle() || $title === $textBlock2->getTitle(); + }), self::callback(function ($content) use ($textBlock1, $textBlock2) { + return $content === $textBlock1->getContent() || $content === $textBlock2->getContent(); + })); + + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + public function testImportNoneTextBlocks(): void { + $textBlocksList = []; + $this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn(json_encode($textBlocksList)); + $this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create'); + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willThrowException(new UserMigrationException()); + $this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create'); + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + public function testImportInvalidJsonThrowsException(): void { + $this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn('this is not valid json {[}'); + + $this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create'); + $this->expectException(UserMigrationException::class); + + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + public function testImportEmptyStringThrowsException(): void { + $this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn(''); + + $this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create'); + $this->expectException(UserMigrationException::class); + + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + public function testImportJsonWithNonArrayRootThrowsException(): void { + $this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn('"just a string"'); + + $this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create'); + $this->expectException(UserMigrationException::class); + + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + private function getLoremIpsum1(): TextBlock { + $textBlock = new TextBlock(); + + $textBlock->setId(1); + $textBlock->setOwner(self::USER_ID); + $textBlock->setTitle('Lorem ipsum 1'); + $textBlock->setPreview('Lorem ipsum dolor sit amet'); + $textBlock->setContent('

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.

'); + + return $textBlock; + } + + private function getIpsumLorem2(): TextBlock { + $textBlock = new TextBlock(); + + $textBlock->setId(2); + $textBlock->setOwner(self::USER_ID); + $textBlock->setTitle('Ipsum lorem 2'); + $textBlock->setPreview('Ipsum lorem amet sit dolor'); + $textBlock->setContent('

At vero eos et accusam et justo duo dolores et ea rebum.

'); + + return $textBlock; + } +}