Skip to content

Commit 48c698f

Browse files
committed
feat(UserMigration): Overwork migration to include all settings (text blocks)
Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
1 parent f64244c commit 48c698f

3 files changed

Lines changed: 273 additions & 0 deletions

File tree

lib/UserMigration/MailAccountMigrator.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCA\Mail\Exception\ServiceException;
1515
use OCA\Mail\UserMigration\Service\AccountMigrationService;
1616
use OCA\Mail\UserMigration\Service\AppConfigMigrationService;
17+
use OCA\Mail\UserMigration\Service\TextBlocksMigrationService;
1718
use OCA\Mail\UserMigration\Service\TrustedSendersMigrationService;
1819
use OCP\AppFramework\Db\DoesNotExistException;
1920
use OCP\IL10N;
@@ -35,6 +36,7 @@ public function __construct(
3536
private readonly AccountMigrationService $accountMigrationService,
3637
private readonly AppConfigMigrationService $appConfigMigrationService,
3738
private readonly TrustedSendersMigrationService $trustedSendersMigrationService,
39+
private readonly TextBlocksMigrationService $textBlocksMigrationService,
3840
) {
3941
}
4042

@@ -47,6 +49,7 @@ public function export(IUser $user,
4749

4850
$this->appConfigMigrationService->exportAppConfiguration($user, $exportDestination, $output);
4951
$this->trustedSendersMigrationService->exportTrustedSenders($user, $exportDestination, $output);
52+
$this->textBlocksMigrationService->exportTextBlocks($user, $exportDestination, $output);
5053
}
5154

5255
#[\Override]
@@ -56,6 +59,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
5659

5760
$this->appConfigMigrationService->importAppConfiguration($user, $importSource, $output);
5861
$this->trustedSendersMigrationService->importTrustedSenders($user, $importSource, $output);
62+
$this->textBlocksMigrationService->importTextBlocks($user, $importSource, $output);
5963

6064
$this->accountMigrationService->scheduleBackgroundJobs($user, $output);
6165
}
@@ -75,6 +79,7 @@ private function deleteExistingData(IUser $user, OutputInterface $output): void
7579

7680
$this->appConfigMigrationService->deleteAppConfiguration($user, $output);
7781
$this->trustedSendersMigrationService->removeAllTrustedSenders($user, $output);
82+
$this->textBlocksMigrationService->deleteAllTextBlocks($user, $output);
7883
$this->accountMigrationService->deleteAllAccounts($user, $output);
7984
}
8085

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Mail\UserMigration\Service;
11+
12+
use JsonException;
13+
use OCA\Mail\Service\TextBlockService;
14+
use OCA\Mail\UserMigration\MailAccountMigrator;
15+
use OCP\IL10N;
16+
use OCP\IUser;
17+
use OCP\UserMigration\IExportDestination;
18+
use OCP\UserMigration\IImportSource;
19+
use OCP\UserMigration\UserMigrationException;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
22+
class TextBlocksMigrationService {
23+
public const TEXT_BLOCKS_FILE = MailAccountMigrator::EXPORT_ROOT . '/text_blocks.json';
24+
25+
public function __construct(
26+
private readonly TextBlockService $textBlockService,
27+
private readonly IL10N $l10n,
28+
) {
29+
}
30+
31+
/**
32+
* Export all text blocks the user created itself.
33+
* This does not include those shared with others.
34+
*
35+
* @throws UserMigrationException
36+
*/
37+
public function exportTextBlocks(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
38+
$output->writeln(
39+
$this->l10n->t('Exporting text blocks for user %s', [$user->getUID()]),
40+
OutputInterface::VERBOSITY_VERBOSE
41+
);
42+
43+
$textBlocks = $this->textBlockService->findAll($user->getUID());
44+
45+
try {
46+
$exportDestination->addFileContents(self::TEXT_BLOCKS_FILE, json_encode($textBlocks, JSON_THROW_ON_ERROR));
47+
} catch (JsonException|UserMigrationException $exception) {
48+
throw new UserMigrationException(
49+
"Failed to export text blocks for user {$user->getUID()}",
50+
previous: $exception
51+
);
52+
}
53+
}
54+
55+
/**
56+
* Import all text blocks the user created itself on export.
57+
* This does not include those shared with others.
58+
*
59+
* @throws UserMigrationException
60+
*/
61+
public function importTextBlocks(IUser $user, IImportSource $importSource, OutputInterface $output): void {
62+
$output->writeln(
63+
$this->l10n->t('Importing text blocks for user %s', [$user->getUID()]),
64+
OutputInterface::VERBOSITY_VERBOSE
65+
);
66+
67+
try {
68+
$textBlocksFileContent = $importSource->getFileContents(self::TEXT_BLOCKS_FILE);
69+
} catch (UserMigrationException) {
70+
$output->writeln(
71+
$this->l10n->t('Text blocks for user %s not found. Continue...', [$user->getUID()]),
72+
OutputInterface::VERBOSITY_VERBOSE
73+
);
74+
75+
return;
76+
}
77+
78+
$textBlocks = json_decode($textBlocksFileContent, true);
79+
$this->validateTextBlocks($textBlocks);
80+
81+
foreach ($textBlocks as $textBlock) {
82+
$output->writeln(
83+
$this->l10n->t('Importing text block %s for user %s', [$textBlock['title'], $user->getUID()]),
84+
OutputInterface::VERBOSITY_VERBOSE
85+
);
86+
87+
$this->textBlockService->create($user->getUID(), $textBlock['title'], $textBlock['content']);
88+
}
89+
}
90+
91+
public function deleteAllTextBlocks(IUser $user, OutputInterface $output): void {
92+
$output->writeln(
93+
$this->l10n->t('Delete existing text blocks for user %s', [$user->getUID()]),
94+
OutputInterface::VERBOSITY_VERBOSE
95+
);
96+
97+
$this->textBlockService->deleteByUserId($user->getUID());
98+
}
99+
100+
/**
101+
* Validate the parsed text blocks to ensure they
102+
* have the expected structure and types.
103+
*
104+
* @throws UserMigrationException
105+
*/
106+
private function validateTextBlocks(mixed $textBlocks): void {
107+
$textBlocksArrayIsValid = is_array($textBlocks) && array_is_list($textBlocks);
108+
if (!$textBlocksArrayIsValid) {
109+
throw new UserMigrationException('Invalid text blocks export structure');
110+
}
111+
112+
foreach ($textBlocks as $textBlock) {
113+
$textBlockArrayIsValid = is_array($textBlock);
114+
115+
$titleIsValid = $textBlockArrayIsValid
116+
&& array_key_exists('title', $textBlock)
117+
&& is_string($textBlock['title']);
118+
119+
$contentIsValid = $textBlockArrayIsValid
120+
&& array_key_exists('content', $textBlock)
121+
&& is_string($textBlock['content']);
122+
123+
if (!$titleIsValid || !$contentIsValid) {
124+
throw new UserMigrationException('Invalid text block entry');
125+
}
126+
}
127+
}
128+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace Unit\UserMigration\Service;
11+
12+
use ChristophWurst\Nextcloud\Testing\ServiceMockObject;
13+
use ChristophWurst\Nextcloud\Testing\TestCase;
14+
use OCA\Mail\Db\TextBlock;
15+
use OCA\Mail\UserMigration\Service\TextBlocksMigrationService;
16+
use OCP\IUser;
17+
use OCP\UserMigration\IExportDestination;
18+
use OCP\UserMigration\IImportSource;
19+
use OCP\UserMigration\UserMigrationException;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
22+
class TextBlocksMigrationServiceTest extends TestCase {
23+
private const USER_ID = '123';
24+
private OutputInterface $output;
25+
private IUser $user;
26+
private IExportDestination $exportDestination;
27+
private IImportSource $importSource;
28+
private ServiceMockObject $serviceMock;
29+
private TextBlocksMigrationService $migrationService;
30+
31+
protected function setUp(): void {
32+
parent::setUp();
33+
34+
$this->output = $this->createMock(OutputInterface::class);
35+
$this->exportDestination = $this->createMock(IExportDestination::class);
36+
$this->importSource = $this->createMock(IImportSource::class);
37+
38+
$this->user = $this->createMock(IUser::class);
39+
$this->user->method('getUID')->willReturn(self::USER_ID);
40+
41+
$this->serviceMock = $this->createServiceMock(TextBlocksMigrationService::class);
42+
$this->migrationService = $this->serviceMock->getService();
43+
}
44+
45+
public function testExportsMultipleTextBlocks(): void {
46+
$textBlocksList = [$this->getLoremIpsum1(), $this->getIpsumLorem2()];
47+
$this->exportDestination->expects(self::once())->method('addFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE, json_encode($textBlocksList));
48+
49+
$this->serviceMock->getParameter('textBlockService')->method('findAll')->with(self::USER_ID)->willReturn($textBlocksList);
50+
$this->migrationService->exportTextBlocks($this->user, $this->exportDestination, $this->output);
51+
}
52+
53+
public function testExportsNoneTextBlocks(): void {
54+
$textBlocksList = [];
55+
56+
$this->serviceMock->getParameter('textBlockService')->method('findAll')->with(self::USER_ID)->willReturn($textBlocksList);
57+
$this->exportDestination->expects(self::once())->method('addFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE, json_encode($textBlocksList));
58+
59+
$this->migrationService->exportTextBlocks($this->user, $this->exportDestination, $this->output);
60+
}
61+
62+
public function testImportMultipleTextBlocks(): void {
63+
$textBlock1 = $this->getLoremIpsum1();
64+
$textBlock2 = $this->getIpsumLorem2();
65+
$textBlocksList = [$textBlock1, $textBlock2];
66+
$this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn(json_encode($textBlocksList));
67+
68+
$this->serviceMock->getParameter('textBlockService')->expects(self::exactly(2))->method('create')->with(self::USER_ID, self::callback(function ($title) use ($textBlock1, $textBlock2) {
69+
return $title === $textBlock1->getTitle() || $title === $textBlock2->getTitle();
70+
}), self::callback(function ($content) use ($textBlock1, $textBlock2) {
71+
return $content === $textBlock1->getContent() || $content === $textBlock2->getContent();
72+
}));
73+
74+
$this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output);
75+
}
76+
77+
public function testImportNoneTextBlocks(): void {
78+
$textBlocksList = [];
79+
$this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn(json_encode($textBlocksList));
80+
$this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create');
81+
$this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output);
82+
}
83+
84+
public function testImportNoFileIsBeingIgnored(): void {
85+
$this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willThrowException(new UserMigrationException());
86+
$this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create');
87+
$this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output);
88+
}
89+
90+
public function testImportInvalidJsonThrowsException(): void {
91+
$this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn('this is not valid json {[}');
92+
93+
$this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create');
94+
$this->expectException(UserMigrationException::class);
95+
96+
$this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output);
97+
}
98+
99+
public function testImportEmptyStringThrowsException(): void {
100+
$this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn('');
101+
102+
$this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create');
103+
$this->expectException(UserMigrationException::class);
104+
105+
$this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output);
106+
}
107+
108+
public function testImportJsonWithNonArrayRootThrowsException(): void {
109+
$this->importSource->expects(self::once())->method('getFileContents')->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE)->willReturn('"just a string"');
110+
111+
$this->serviceMock->getParameter('textBlockService')->expects(self::never())->method('create');
112+
$this->expectException(UserMigrationException::class);
113+
114+
$this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output);
115+
}
116+
117+
private function getLoremIpsum1(): TextBlock {
118+
$textBlock = new TextBlock();
119+
120+
$textBlock->setId(1);
121+
$textBlock->setOwner(self::USER_ID);
122+
$textBlock->setTitle('Lorem ipsum 1');
123+
$textBlock->setPreview('Lorem ipsum dolor sit amet');
124+
$textBlock->setContent('<p style="margin:0;">Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>');
125+
126+
return $textBlock;
127+
}
128+
129+
private function getIpsumLorem2(): TextBlock {
130+
$textBlock = new TextBlock();
131+
132+
$textBlock->setId(2);
133+
$textBlock->setOwner(self::USER_ID);
134+
$textBlock->setTitle('Ipsum lorem 2');
135+
$textBlock->setPreview('Ipsum lorem amet sit dolor');
136+
$textBlock->setContent('<p style="margin:0;">At vero eos et accusam et justo duo dolores et ea rebum.</p>');
137+
138+
return $textBlock;
139+
}
140+
}

0 commit comments

Comments
 (0)