diff --git a/lib/Db/TagMapper.php b/lib/Db/TagMapper.php index 41e25ed895..5334dbdd58 100644 --- a/lib/Db/TagMapper.php +++ b/lib/Db/TagMapper.php @@ -228,6 +228,18 @@ public function createDefaultTags(MailAccount $account): void { } } + /** + * @throws \OCP\DB\Exception + */ + public function deleteAll(string $userId): void { + $qb = $this->db->getQueryBuilder(); + $delete = $qb->delete($this->getTableName()) + ->where( + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId)) + ); + $delete->executeStatement(); + } + public function deleteDuplicates(): void { $qb = $this->db->getQueryBuilder(); $qb->select('mt2.id') diff --git a/lib/UserMigration/MailAccountMigrator.php b/lib/UserMigration/MailAccountMigrator.php index 3a1e51eae6..98e43eb987 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\TagsMigrationService; use OCA\Mail\UserMigration\Service\TextBlocksMigrationService; use OCA\Mail\UserMigration\Service\TrustedSendersMigrationService; use OCP\AppFramework\Db\DoesNotExistException; @@ -37,6 +38,7 @@ public function __construct( private readonly AppConfigMigrationService $appConfigMigrationService, private readonly TrustedSendersMigrationService $trustedSendersMigrationService, private readonly TextBlocksMigrationService $textBlocksMigrationService, + private readonly TagsMigrationService $tagsMigrationService, ) { } @@ -50,16 +52,19 @@ public function export(IUser $user, $this->appConfigMigrationService->exportAppConfiguration($user, $exportDestination, $output); $this->trustedSendersMigrationService->exportTrustedSenders($user, $exportDestination, $output); $this->textBlocksMigrationService->exportTextBlocks($user, $exportDestination, $output); + $this->tagsMigrationService->exportTags($user, $exportDestination, $output); } #[\Override] public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { $output->writeln($this->l10n->t("Importing mail accounts for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE); + $this->deleteExistingData($user, $output); $this->appConfigMigrationService->importAppConfiguration($user, $importSource, $output); $this->trustedSendersMigrationService->importTrustedSenders($user, $importSource, $output); $this->textBlocksMigrationService->importTextBlocks($user, $importSource, $output); + $newTagIds = $this->tagsMigrationService->importTags($user, $importSource, $output); $this->accountMigrationService->scheduleBackgroundJobs($user, $output); } @@ -80,6 +85,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->tagsMigrationService->deleteAllTags($user, $output); $this->accountMigrationService->deleteAllAccounts($user, $output); } diff --git a/lib/UserMigration/Service/TagsMigrationService.php b/lib/UserMigration/Service/TagsMigrationService.php new file mode 100644 index 0000000000..630f47ba2b --- /dev/null +++ b/lib/UserMigration/Service/TagsMigrationService.php @@ -0,0 +1,156 @@ +writeln( + $this->l10n->t('Exporting tags for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + $tags = $this->tagMapper->getAllTagsForUser($user->getUID()); + + try { + $exportDestination->addFileContents(self::TAGS_FILE, json_encode($tags, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export tags for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import all tags the user used on export. + * + * @throws \OCP\UserMigration\UserMigrationException + * @throws \OCP\DB\Exception + */ + public function importTags(IUser $user, IImportSource $importSource, OutputInterface $output): array { + $output->writeln( + $this->l10n->t('Importing tags for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + try { + $tagsFileContent = $importSource->getFileContents(self::TAGS_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t('Tags for user %s not found. Continue...', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + return []; + } + + $tags = json_decode($tagsFileContent, true); + $this->validateTags($tags); + + $newTags = []; + + foreach ($tags as $tag) { + $newTag = new Tag(); + + $newTag->setUserId($user->getUID()); + $newTag->setDisplayName($tag['displayName']); + $newTag->setImapLabel($tag['imapLabel']); + $newTag->setColor($tag['color']); + $newTag->setIsDefaultTag($tag['isDefaultTag']); + + $newTags[$tag['id']] = $this->tagMapper->insert($newTag)->getId(); + } + + return $newTags; + } + + /** + * @throws \OCP\DB\Exception + */ + public function deleteAllTags(IUser $user, OutputInterface $output): void { + $output->writeln( + $this->l10n->t('Delete existing tags for user %s', [$user->getUID()]), + OutputInterface::VERBOSITY_VERBOSE + ); + + $this->tagMapper->deleteAll($user->getUID()); + } + + /** + * Validate the parsed tags to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateTags(mixed $tags): void { + $tagsArrayIsValid = is_array($tags) && array_is_list($tags); + if (!$tagsArrayIsValid) { + throw new UserMigrationException('Invalid tags export structure'); + } + + foreach ($tags as $tag) { + $tagArrayIsValid = is_array($tag); + + $idIsValid = $tagArrayIsValid + && array_key_exists('id', $tag) + && is_int($tag['id']); + + $displayNameIsValid = $tagArrayIsValid + && array_key_exists('displayName', $tag) + && is_string($tag['displayName']); + + $imapLabelIsValid = $tagArrayIsValid + && array_key_exists('imapLabel', $tag) + && is_string($tag['imapLabel']); + + $colorIsValid = $tagArrayIsValid + && array_key_exists('color', $tag) + && is_string($tag['color']); + + $isDefaultTagIsValid = $tagArrayIsValid + && array_key_exists('isDefaultTag', $tag) + && is_bool($tag['isDefaultTag']); + + if ( + !$idIsValid + || !$displayNameIsValid + || !$imapLabelIsValid + || !$colorIsValid + || !$isDefaultTagIsValid + ) { + throw new UserMigrationException('Invalid tag entry'); + } + } + } +} diff --git a/tests/Unit/UserMigration/Service/TagsMigrationServiceTest.php b/tests/Unit/UserMigration/Service/TagsMigrationServiceTest.php new file mode 100644 index 0000000000..99de889fc6 --- /dev/null +++ b/tests/Unit/UserMigration/Service/TagsMigrationServiceTest.php @@ -0,0 +1,165 @@ +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(TagsMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + } + + public function testExportsMultipleTags(): void { + $tagsList = [$this->getTestingTag(), $this->getSuccessfulTag()]; + $this->exportDestination->expects(self::once())->method('addFileContents')->with(TagsMigrationService::TAGS_FILE, json_encode($tagsList)); + + $this->serviceMock->getParameter('tagMapper')->method('getAllTagsForUser')->with(self::USER_ID)->willReturn($tagsList); + $this->migrationService->exportTags($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoTags(): void { + $tagsList = []; + + $this->serviceMock->getParameter('tagMapper')->method('getAllTagsForUser')->with(self::USER_ID)->willReturn($tagsList); + $this->exportDestination->expects(self::once())->method('addFileContents')->with(TagsMigrationService::TAGS_FILE, json_encode($tagsList)); + + $this->migrationService->exportTags($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleTags(): void { + $testingTag = $this->getTestingTag(); + $successfulTag = $this->getSuccessfulTag(); + $tagsList = [$testingTag, $successfulTag]; + $this->importSource->expects(self::once())->method('getFileContents')->with(TagsMigrationService::TAGS_FILE)->willReturn(json_encode($tagsList)); + + $this->serviceMock->getParameter('tagMapper')->expects(self::exactly(2))->method('insert')->with(self::callback(function (Tag $writtenTag) use ($testingTag, $successfulTag): bool { + if ($this->userIdMatches($writtenTag) + && $this->displayNameMatches($writtenTag) + && $this->imapLabelMatches($writtenTag) + && $this->colorMatches($writtenTag) + && $this->isDefaultTagMatches($writtenTag) + ) { + return true; + } else { + return false; + } + }))->willReturnCallback(function ($test) { + $test->setId(random_int(10, 999)); + return $test; + }); + + $mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output); + + $this->assertCount(2, $mappedTags); + $this->assertArrayHasKey($testingTag->getId(), $mappedTags); + $this->assertIsInt($mappedTags[$testingTag->getId()]); + $this->assertArrayHasKey($successfulTag->getId(), $mappedTags); + $this->assertIsInt($mappedTags[$successfulTag->getId()]); + } + + public function testImportNoTags(): void { + $tagsList = []; + $this->importSource->expects(self::once())->method('getFileContents')->with(TagsMigrationService::TAGS_FILE)->willReturn(json_encode($tagsList)); + $this->serviceMock->getParameter('tagMapper')->expects(self::never())->method('insert'); + $mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output); + $this->assertCount(0, $mappedTags); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource->expects(self::once())->method('getFileContents')->with(TagsMigrationService::TAGS_FILE)->willThrowException(new UserMigrationException()); + $this->serviceMock->getParameter('tagMapper')->expects(self::never())->method('insert'); + $mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output); + $this->assertCount(0, $mappedTags); + } + + private function getTestingTag(): Tag { + $testingTag = new Tag(); + + $testingTag->setId(1); + $testingTag->setUserId(self::USER_ID); + $testingTag->setImapLabel('testing'); + $testingTag->setDisplayName('Testing'); + $testingTag->setColor('#fff'); + $testingTag->setIsDefaultTag(false); + + return $testingTag; + } + + private function getSuccessfulTag(): Tag { + $successfulTag = new Tag(); + + $successfulTag->setId(2); + $successfulTag->setUserId(self::USER_ID); + $successfulTag->setImapLabel('successful'); + $successfulTag->setDisplayName('Successful'); + $successfulTag->setColor('#fff'); + $successfulTag->setIsDefaultTag(false); + + return $successfulTag; + } + + private function userIdMatches(Tag $tag): bool { + return $tag->getUserId() === self::USER_ID; + } + + private function displayNameMatches(Tag $tag): bool { + $testing = $this->getTestingTag(); + $successful = $this->getSuccessfulTag(); + + return $tag->getDisplayName() === $testing->getDisplayName() || $tag->getDisplayName() === $successful->getDisplayName(); + } + + private function imapLabelMatches(Tag $tag): bool { + $testing = $this->getTestingTag(); + $successful = $this->getSuccessfulTag(); + + return $tag->getImapLabel() === $testing->getImapLabel() || $tag->getImapLabel() === $successful->getImapLabel(); + } + + private function colorMatches(Tag $tag): bool { + $testing = $this->getTestingTag(); + $successful = $this->getSuccessfulTag(); + + return $tag->getColor() === $testing->getColor() || $tag->getColor() === $successful->getColor(); + } + + private function isDefaultTagMatches(Tag $tag): bool { + $testing = $this->getTestingTag(); + $successful = $this->getSuccessfulTag(); + + return $tag->getIsDefaultTag() === $testing->getIsDefaultTag() || $tag->getIsDefaultTag() === $successful->getIsDefaultTag(); + } +}