diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index b6ca34662..07348c5c9 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -16,5 +16,9 @@ // Import commands.js using ES2015 syntax: import './commands.js' +Cypress.on('uncaught:exception', (err) => { + return !err.message.includes('ResizeObserver loop limit exceeded') +}) + // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/docs/export-import.md b/docs/export-import.md new file mode 100644 index 000000000..ca7cd840a --- /dev/null +++ b/docs/export-import.md @@ -0,0 +1,98 @@ +## Export + +Deck currently supports exporting all boards a user owns in a single JSON file. The format is based on the database schema that deck uses. It can be used to re-import boards on the same or other instances. + +The export currently has some kown limitations in terms of specific data not included: +- Activity information +- File attachments to deck cards +- Comments +- +``` +occ deck:export > my-file.json +``` + +## Import boards + +Importing can be done using the API or the `occ` `deck:import` command. + +It is possible to import from the following sources: + +### Deck JSON + +A json file that has been obtained from the above described `occ deck:export [userid]` command can be imported. + +``` +occ deck:import my-file.json +``` + +In case you are importing from a different instance you may use an additional config file to provide custom user id mapping in case users have different identifiers. + +``` +{ + "owner": "admin", + "uidRelation": { + "johndoe": "test-user-1" + } +} +``` + +#### Trello JSON + +Limitations: +* Comments with more than 1000 characters are placed as attached files to the card. + +Steps: +* Create the data file + * Access Trello + * go to the board you want to export + * Follow the steps in [Trello documentation](https://help.trello.com/article/747-exporting-data-from-trello-1) and export as JSON +* Create the configuration file +* Execute the import informing the import file path, data file and source as `Trello JSON` + +Create the configuration file respecting the [JSON Schema](https://github.com/nextcloud/deck/blob/main/lib/Service/Importer/fixtures/config-trelloJson-schema.json) for import `Trello JSON` + +Example configuration file: +```json +{ + "owner": "admin", + "color": "0800fd", + "uidRelation": { + "johndoe": "johndoe" + } +} +``` + +**Limitations**: + +Importing from a JSON file imports up to 1000 actions. To find out how many actions the board to be imported has, identify how many actions the JSON has. + +#### Trello API + +Import using API is recommended for boards with more than 1000 actions. + +Trello makes it possible to attach links to a card. Deck does not have this feature. Attachments and attachment links are added in a markdown table at the end of the description for every imported card that has attachments in Trello. + +* Get the API Key and API Token [here](https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/#authentication-and-authorization) +* Get the ID of the board you want to import by making a request to: + https://api.trello.com/1/members/me/boards?key={yourKey}&token={yourToken}&fields=id,name + + This ID you will use in the configuration file in the `board` property +* Create the configuration file + +Create the configuration file respecting the [JSON Schema](https://github.com/nextcloud/deck/blob/main/lib/Service/Importer/fixtures/config-trelloApi-schema.json) for import `Trello JSON` + +Example configuration file: +```json +{ + "owner": "admin", + "color": "0800fd", + "api": { + "key": "0cc175b9c0f1b6a831c399e269772661", + "token": "92eb5ffee6ae2fec3ad71c777531578f4a8a08f09d37b73795649038408b5f33" + }, + "board": "8277e0910d750195b4487976", + "uidRelation": { + "johndoe": "johndoe" + } +} +``` diff --git a/lib/Command/BoardImport.php b/lib/Command/BoardImport.php index ae0388f91..66ee33b3e 100644 --- a/lib/Command/BoardImport.php +++ b/lib/Command/BoardImport.php @@ -25,17 +25,15 @@ use OCA\Deck\Service\Importer\BoardImportCommandService; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class BoardImport extends Command { - private BoardImportCommandService $boardImportCommandService; - public function __construct( - BoardImportCommandService $boardImportCommandService + private BoardImportCommandService $boardImportCommandService ) { - $this->boardImportCommandService = $boardImportCommandService; parent::__construct(); } @@ -44,7 +42,9 @@ public function __construct( */ protected function configure() { $allowedSystems = $this->boardImportCommandService->getAllowedImportSystems(); - $names = array_column($allowedSystems, 'name'); + $names = array_map(function ($name) { + return '"' . $name . '"'; + }, array_column($allowedSystems, 'internalName')); $this ->setName('deck:import') ->setDescription('Import data') @@ -53,7 +53,7 @@ protected function configure() { null, InputOption::VALUE_REQUIRED, 'Source system for import. Available options: ' . implode(', ', $names) . '.', - null + 'DeckJson', ) ->addOption( 'config', @@ -69,6 +69,11 @@ protected function configure() { 'Data file to import.', 'data.json' ) + ->addArgument( + 'file', + InputArgument::OPTIONAL, + 'File to import', + ) ; } diff --git a/lib/Command/UserExport.php b/lib/Command/UserExport.php index 3b2948270..7355cb880 100644 --- a/lib/Command/UserExport.php +++ b/lib/Command/UserExport.php @@ -29,39 +29,23 @@ use OCA\Deck\Db\StackMapper; use OCA\Deck\Model\CardDetails; use OCA\Deck\Service\BoardService; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\AppFramework\Db\MultipleObjectsReturnedException; -use OCP\IGroupManager; -use OCP\IUserManager; +use OCP\App\IAppManager; +use OCP\DB\Exception; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class UserExport extends Command { - protected $boardService; - protected $cardMapper; - private $userManager; - private $groupManager; - private $assignedUsersMapper; - - public function __construct(BoardMapper $boardMapper, - BoardService $boardService, - StackMapper $stackMapper, - CardMapper $cardMapper, - AssignmentMapper $assignedUsersMapper, - IUserManager $userManager, - IGroupManager $groupManager) { + public function __construct( + private IAppManager $appManager, + private BoardMapper $boardMapper, + private BoardService $boardService, + private StackMapper $stackMapper, + private CardMapper $cardMapper, + private AssignmentMapper $assignedUsersMapper, + ) { parent::__construct(); - - $this->cardMapper = $cardMapper; - $this->boardService = $boardService; - $this->stackMapper = $stackMapper; - $this->assignedUsersMapper = $assignedUsersMapper; - $this->boardMapper = $boardMapper; - - $this->userManager = $userManager; - $this->groupManager = $groupManager; } protected function configure() { @@ -73,30 +57,27 @@ protected function configure() { InputArgument::REQUIRED, 'User ID of the user' ) + ->addOption('legacy-format', 'l') ; } /** - * @param InputInterface $input - * @param OutputInterface $output - * @return int - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - * @throws \ReflectionException + * @throws Exception */ protected function execute(InputInterface $input, OutputInterface $output): int { $userId = $input->getArgument('user-id'); + $legacyFormat = $input->getOption('legacy-format'); $this->boardService->setUserId($userId); - $boards = $this->boardService->findAll(); + $boards = $this->boardService->findAll(fullDetails: false); $data = []; foreach ($boards as $board) { $fullBoard = $this->boardMapper->find($board->getId(), true, true); - $data[$board->getId()] = (array)$fullBoard->jsonSerialize(); + $data[$board->getId()] = $fullBoard->jsonSerialize(); $stacks = $this->stackMapper->findAll($board->getId()); foreach ($stacks as $stack) { - $data[$board->getId()]['stacks'][] = (array)$stack->jsonSerialize(); + $data[$board->getId()]['stacks'][$stack->getId()] = $stack->jsonSerialize(); $cards = $this->cardMapper->findAllByStack($stack->getId()); foreach ($cards as $card) { $fullCard = $this->cardMapper->find($card->getId()); @@ -108,7 +89,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } } - $output->writeln(json_encode($data, JSON_PRETTY_PRINT)); + $output->writeln(json_encode( + $legacyFormat ? $data : [ + 'version' => $this->appManager->getAppVersion('deck'), + 'boards' => $data + ], + JSON_PRETTY_PRINT)); return 0; } } diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index 56207d36a..282581d58 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -41,4 +41,17 @@ public function __construct() { $this->addType('type', 'integer'); $this->addResolvable('participant'); } + + public function getTypeString(): string { + switch ($this->getType()) { + case self::TYPE_USER: + return 'user'; + case self::TYPE_GROUP: + return 'group'; + case self::TYPE_CIRCLE: + return 'circle'; + } + + return 'unknown'; + } } diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index 8f44c003b..e4a04a9ac 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -532,12 +532,12 @@ public function flushCache(?int $boardId = null, ?string $userId = null) { if ($boardId) { unset($this->boardCache[$boardId]); } else { - $this->boardCache = null; + $this->boardCache = new CappedMemoryCache(); } if ($userId) { unset($this->userBoardCache[$userId]); } else { - $this->userBoardCache = null; + $this->userBoardCache = new CappedMemoryCache(); } } } diff --git a/lib/Db/LabelMapper.php b/lib/Db/LabelMapper.php index aeccef539..66fcfa0dd 100644 --- a/lib/Db/LabelMapper.php +++ b/lib/Db/LabelMapper.php @@ -115,7 +115,9 @@ public function findAssignedLabelsForBoard($boardId, $limit = null, $offset = nu } public function insert(Entity $entity): Entity { - $entity->setLastModified(time()); + if (!in_array('lastModified', $entity->getUpdatedFields())) { + $entity->setLastModified(time()); + } return parent::insert($entity); } diff --git a/lib/Db/RelationalEntity.php b/lib/Db/RelationalEntity.php index 8c27daba5..919d40ca1 100644 --- a/lib/Db/RelationalEntity.php +++ b/lib/Db/RelationalEntity.php @@ -138,7 +138,7 @@ public function __call(string $methodName, array $args) { $attr = lcfirst(substr($methodName, 3)); if (array_key_exists($attr, $this->_resolvedProperties) && str_starts_with($methodName, 'set')) { - if (!is_scalar($args[0])) { + if ($args[0] !== null && !is_scalar($args[0])) { $args[0] = $args[0]['primaryKey']; } parent::setter($attr, $args); diff --git a/lib/Service/Importer/ABoardImportService.php b/lib/Service/Importer/ABoardImportService.php index 2e6acb605..43ec745f5 100644 --- a/lib/Service/Importer/ABoardImportService.php +++ b/lib/Service/Importer/ABoardImportService.php @@ -61,6 +61,10 @@ abstract class ABoardImportService { */ abstract public function bootstrap(): void; + public function getBoards(): array { + return [$this->getImportService()->getData()]; + } + abstract public function getBoard(): ?Board; /** @@ -133,4 +137,13 @@ public function getImportService(): BoardImportService { public function needValidateData(): bool { return $this->needValidateData; } + + public function reset(): void { + // FIXME: Would be cleaner if we could just get a new instance per board + // but currently https://github.com/nextcloud/deck/blob/7d820aa3f9fc69ada8188549b9a2fbb9093ffb95/lib/Service/Importer/BoardImportService.php#L194 returns a singleton + $this->labels = []; + $this->stacks = []; + $this->acls = []; + $this->cards = []; + } } diff --git a/lib/Service/Importer/BoardImportCommandService.php b/lib/Service/Importer/BoardImportCommandService.php index d45c784de..7be2877d8 100644 --- a/lib/Service/Importer/BoardImportCommandService.php +++ b/lib/Service/Importer/BoardImportCommandService.php @@ -78,6 +78,10 @@ public function getOutput(): OutputInterface { protected function validateConfig(): void { try { $config = $this->getInput()->getOption('config'); + if (!$config) { + return; + } + if (is_string($config)) { if (!is_file($config)) { throw new NotFoundException('It\'s not a valid config file.'); @@ -95,7 +99,7 @@ protected function validateConfig(): void { $helper = $this->getCommand()->getHelper('question'); $question = new Question( "You can get more info on https://deck.readthedocs.io/en/latest/User_documentation_en/#6-import-boards\n" . - 'Please inform a valid config json file: ', + 'Please provide a valid config json file: ', 'config.json' ); $question->setValidator(function (string $answer) { @@ -130,7 +134,7 @@ public function validateSystem(): void { $allowedSystems = $this->getAllowedImportSystems(); $names = array_column($allowedSystems, 'name'); $question = new ChoiceQuestion( - 'Please inform a source system', + 'Please select a source system', $names, 0 ); @@ -145,6 +149,18 @@ protected function validateData(): void { if (!$this->getImportSystem()->needValidateData()) { return; } + $data = $this->getInput()->getArgument('file'); + if (is_string($data)) { + if (!file_exists($data)) { + throw new \OCP\Files\NotFoundException('Could not find file ' . $data); + } + $data = json_decode(file_get_contents($data)); + if ($data instanceof \stdClass) { + $this->setData($data); + return; + } + } + $data = $this->getInput()->getOption('data'); if (is_string($data)) { $data = json_decode(file_get_contents($data)); @@ -174,26 +190,56 @@ protected function validateData(): void { public function bootstrap(): void { $this->setSystem($this->getInput()->getOption('system')); parent::bootstrap(); + + $this->registerErrorCollector(function ($error, $exception) { + $message = $error; + if ($exception instanceof \Throwable) { + $message .= ': ' . $exception->getMessage(); + } + $this->getOutput()->writeln('' . $message . ''); + if ($exception instanceof \Throwable && $this->getOutput()->isVeryVerbose()) { + $this->getOutput()->writeln($exception->getTraceAsString()); + } + }); + + $this->registerOutputCollector(function ($info) { + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->writeln('' . $info . '', ); + } + }); } public function import(): void { $this->getOutput()->writeln('Starting import...'); $this->bootstrap(); - $this->getOutput()->writeln('Importing board...'); - $this->importBoard(); - $this->getOutput()->writeln('Assign users to board...'); - $this->importAcl(); - $this->getOutput()->writeln('Importing labels...'); - $this->importLabels(); - $this->getOutput()->writeln('Importing stacks...'); - $this->importStacks(); - $this->getOutput()->writeln('Importing cards...'); - $this->importCards(); - $this->getOutput()->writeln('Assign cards to labels...'); - $this->assignCardsToLabels(); - $this->getOutput()->writeln('Importing comments...'); - $this->importComments(); - $this->getOutput()->writeln('Importing participants...'); - $this->importCardAssignments(); + $this->validateSystem(); + $this->validateConfig(); + $boards = $this->getImportSystem()->getBoards(); + + foreach ($boards as $board) { + try { + $this->reset(); + $this->setData($board); + $this->getOutput()->writeln('Importing board "' . $board->title . '".'); + $this->importBoard(); + $this->getOutput()->writeln('Assign users to board...'); + $this->importAcl(); + $this->getOutput()->writeln('Importing labels...'); + $this->importLabels(); + $this->getOutput()->writeln('Importing stacks...'); + $this->importStacks(); + $this->getOutput()->writeln('Importing cards...'); + $this->importCards(); + $this->getOutput()->writeln('Assign cards to labels...'); + $this->assignCardsToLabels(); + $this->getOutput()->writeln('Importing comments...'); + $this->importComments(); + $this->getOutput()->writeln('Importing participants...'); + $this->importCardAssignments(); + $this->getOutput()->writeln('Finished board import of "' . $this->getBoard()->getTitle() . '"'); + } catch (\Exception $e) { + $this->output->writeln('Import failed for board ' . $board->title . ': ' . $e->getMessage() . ''); + } + } } } diff --git a/lib/Service/Importer/BoardImportService.php b/lib/Service/Importer/BoardImportService.php index 657f938b2..43677fdc5 100644 --- a/lib/Service/Importer/BoardImportService.php +++ b/lib/Service/Importer/BoardImportService.php @@ -40,6 +40,7 @@ use OCA\Deck\Exceptions\ConflictException; use OCA\Deck\NotFoundException; use OCA\Deck\Service\FileService; +use OCA\Deck\Service\Importer\Systems\DeckJsonService; use OCA\Deck\Service\Importer\Systems\TrelloApiService; use OCA\Deck\Service\Importer\Systems\TrelloJsonService; use OCP\Comments\IComment; @@ -48,20 +49,11 @@ use OCP\EventDispatcher\IEventDispatcher; use OCP\IUserManager; use OCP\Server; +use Psr\Log\LoggerInterface; class BoardImportService { - private IUserManager $userManager; - private BoardMapper $boardMapper; - private AclMapper $aclMapper; - private LabelMapper $labelMapper; - private StackMapper $stackMapper; - private CardMapper $cardMapper; - private AssignmentMapper $assignmentMapper; - private AttachmentMapper $attachmentMapper; - private ICommentsManager $commentsManager; - private IEventDispatcher $eventDispatcher; private string $system = ''; - private ?ABoardImportService $systemInstance; + private ?ABoardImportService $systemInstance = null; private array $allowedSystems = []; /** * Data object created from config JSON @@ -79,30 +71,50 @@ class BoardImportService { private $data; private Board $board; + /** @var callable[] */ + private array $errorCollectors = []; + /** @var callable[] */ + private array $outputCollectors = []; + public function __construct( - IUserManager $userManager, - BoardMapper $boardMapper, - AclMapper $aclMapper, - LabelMapper $labelMapper, - StackMapper $stackMapper, - AssignmentMapper $assignmentMapper, - AttachmentMapper $attachmentMapper, - CardMapper $cardMapper, - ICommentsManager $commentsManager, - IEventDispatcher $eventDispatcher + private IUserManager $userManager, + private BoardMapper $boardMapper, + private AclMapper $aclMapper, + private LabelMapper $labelMapper, + private StackMapper $stackMapper, + private AssignmentMapper $assignmentMapper, + private AttachmentMapper $attachmentMapper, + private CardMapper $cardMapper, + private ICommentsManager $commentsManager, + private IEventDispatcher $eventDispatcher, + private LoggerInterface $logger ) { - $this->userManager = $userManager; - $this->boardMapper = $boardMapper; - $this->aclMapper = $aclMapper; - $this->labelMapper = $labelMapper; - $this->stackMapper = $stackMapper; - $this->cardMapper = $cardMapper; - $this->assignmentMapper = $assignmentMapper; - $this->attachmentMapper = $attachmentMapper; - $this->commentsManager = $commentsManager; - $this->eventDispatcher = $eventDispatcher; $this->board = new Board(); $this->disableCommentsEvents(); + + $this->config = new \stdClass(); + } + + public function registerErrorCollector(callable $errorCollector): void { + $this->errorCollectors[] = $errorCollector; + } + + public function registerOutputCollector(callable $outputCollector): void { + $this->outputCollectors[] = $outputCollector; + } + + private function addError(string $message, $exception): void { + $message .= ' (on board ' . $this->getBoard()->getTitle() . ')'; + foreach ($this->errorCollectors as $errorCollector) { + $errorCollector($message, $exception); + } + $this->logger->error($message, ['exception' => $exception]); + } + + private function addOutput(string $message): void { + foreach ($this->outputCollectors as $outputCollector) { + $outputCollector($message); + } } private function disableCommentsEvents(): void { @@ -120,17 +132,23 @@ private function disableCommentsEvents(): void { public function import(): void { $this->bootstrap(); - try { - $this->importBoard(); - $this->importAcl(); - $this->importLabels(); - $this->importStacks(); - $this->importCards(); - $this->assignCardsToLabels(); - $this->importComments(); - $this->importCardAssignments(); - } catch (\Throwable $th) { - throw new BadRequestException($th->getMessage()); + $boards = $this->getImportSystem()->getBoards(); + foreach ($boards as $board) { + try { + $this->reset(); + $this->setData($board); + $this->importBoard(); + $this->importAcl(); + $this->importLabels(); + $this->importStacks(); + $this->importCards(); + $this->assignCardsToLabels(); + $this->importComments(); + $this->importCardAssignments(); + } catch (\Throwable $th) { + $this->logger->error('Failed to import board', ['exception' => $th]); + throw new BadRequestException($th->getMessage()); + } } } @@ -138,7 +156,7 @@ public function validateSystem(): void { $allowedSystems = $this->getAllowedImportSystems(); $allowedSystems = array_column($allowedSystems, 'internalName'); if (!in_array($this->getSystem(), $allowedSystems)) { - throw new NotFoundException('Invalid system'); + throw new NotFoundException('Invalid system: ' . $this->getSystem()); } } @@ -164,6 +182,11 @@ public function addAllowedImportSystem($system): self { public function getAllowedImportSystems(): array { if (!$this->allowedSystems) { + $this->addAllowedImportSystem([ + 'name' => DeckJsonService::$name, + 'class' => DeckJsonService::class, + 'internalName' => 'DeckJson' + ]); $this->addAllowedImportSystem([ 'name' => TrelloApiService::$name, 'class' => TrelloApiService::class, @@ -195,8 +218,17 @@ public function setImportSystem(ABoardImportService $instance): void { $this->systemInstance = $instance; } + public function reset(): void { + $this->board = new Board(); + $this->getImportSystem()->reset(); + } + public function importBoard(): void { $board = $this->getImportSystem()->getBoard(); + if (!$this->userManager->userExists($board->getOwner())) { + throw new \Exception('Target owner ' . $board->getOwner() . ' not found. Please provide a mapping through the import config.'); + } + if ($board) { $this->boardMapper->insert($board); $this->board = $board; @@ -213,8 +245,12 @@ public function getBoard(bool $reset = false): Board { public function importAcl(): void { $aclList = $this->getImportSystem()->getAclList(); foreach ($aclList as $code => $acl) { - $this->aclMapper->insert($acl); - $this->getImportSystem()->updateAcl($code, $acl); + try { + $this->aclMapper->insert($acl); + $this->getImportSystem()->updateAcl($code, $acl); + } catch (\Exception $e) { + $this->addError('Failed to import acl rule for ' . $acl->getParticipant(), $e); + } } $this->getBoard()->setAcl($aclList); } @@ -222,8 +258,12 @@ public function importAcl(): void { public function importLabels(): void { $labels = $this->getImportSystem()->getLabels(); foreach ($labels as $code => $label) { - $this->labelMapper->insert($label); - $this->getImportSystem()->updateLabel($code, $label); + try { + $this->labelMapper->insert($label); + $this->getImportSystem()->updateLabel($code, $label); + } catch (\Exception $e) { + $this->addError('Failed to import label ' . $label->getTitle(), $e); + } } $this->getBoard()->setLabels($labels); } @@ -231,8 +271,12 @@ public function importLabels(): void { public function importStacks(): void { $stacks = $this->getImportSystem()->getStacks(); foreach ($stacks as $code => $stack) { - $this->stackMapper->insert($stack); - $this->getImportSystem()->updateStack($code, $stack); + try { + $this->stackMapper->insert($stack); + $this->getImportSystem()->updateStack($code, $stack); + } catch (\Exception $e) { + $this->addError('Failed to import list ' . $stack->getTitle(), $e); + } } $this->getBoard()->setStacks(array_values($stacks)); } @@ -240,22 +284,26 @@ public function importStacks(): void { public function importCards(): void { $cards = $this->getImportSystem()->getCards(); foreach ($cards as $code => $card) { - $createdAt = $card->getCreatedAt(); - $lastModified = $card->getLastModified(); - $this->cardMapper->insert($card); - $updateDate = false; - if ($createdAt && $createdAt !== $card->getCreatedAt()) { - $card->setCreatedAt($createdAt); - $updateDate = true; - } - if ($lastModified && $lastModified !== $card->getLastModified()) { - $card->setLastModified($lastModified); - $updateDate = true; - } - if ($updateDate) { - $this->cardMapper->update($card, false); + try { + $createdAt = $card->getCreatedAt(); + $lastModified = $card->getLastModified(); + $this->cardMapper->insert($card); + $updateDate = false; + if ($createdAt && $createdAt !== $card->getCreatedAt()) { + $card->setCreatedAt($createdAt); + $updateDate = true; + } + if ($lastModified && $lastModified !== $card->getLastModified()) { + $card->setLastModified($lastModified); + $updateDate = true; + } + if ($updateDate) { + $this->cardMapper->update($card, false); + } + $this->getImportSystem()->updateCard($code, $card); + } catch (\Exception $e) { + $this->addError('Failed to import card ' . $card->getTitle(), $e); } - $this->getImportSystem()->updateCard($code, $card); } } @@ -276,11 +324,15 @@ public function assignCardsToLabels(): void { $data = $this->getImportSystem()->getCardLabelAssignment(); foreach ($data as $cardId => $assignemnt) { foreach ($assignemnt as $assignmentId => $labelId) { - $this->assignCardToLabel( - $cardId, - $labelId - ); - $this->getImportSystem()->updateCardLabelsAssignment($cardId, $assignmentId, $labelId); + try { + $this->assignCardToLabel( + $cardId, + $labelId + ); + $this->getImportSystem()->updateCardLabelsAssignment($cardId, $assignmentId, $labelId); + } catch (\Exception $e) { + $this->addError('Failed to assign label ' . $labelId . ' to ' . $cardId, $e); + } } } } @@ -322,9 +374,14 @@ private function insertComment(string $cardId, IComment $comment): void { public function importCardAssignments(): void { $allAssignments = $this->getImportSystem()->getCardAssignments(); foreach ($allAssignments as $cardId => $assignments) { - foreach ($assignments as $assignmentId => $assignment) { - $this->assignmentMapper->insert($assignment); - $this->getImportSystem()->updateCardAssignment($cardId, $assignmentId, $assignment); + foreach ($assignments as $assignment) { + try { + $assignment = $this->assignmentMapper->insert($assignment); + $this->getImportSystem()->updateCardAssignment($cardId, (string)$assignment->getId(), $assignment); + $this->addOutput('Assignment ' . $assignment->getParticipant() . ' added'); + } catch (NotFoundException $e) { + $this->addError('No origin or mapping found for card "' . $cardId . '" and ' . $assignment->getTypeString() .' assignment "' . $assignment->getParticipant(), $e); + } } } } diff --git a/lib/Service/Importer/Systems/DeckJsonService.php b/lib/Service/Importer/Systems/DeckJsonService.php new file mode 100644 index 000000000..10159b15b --- /dev/null +++ b/lib/Service/Importer/Systems/DeckJsonService.php @@ -0,0 +1,260 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Deck\Service\Importer\Systems; + +use OCA\Deck\BadRequestException; +use OCA\Deck\Db\Acl; +use OCA\Deck\Db\Assignment; +use OCA\Deck\Db\Board; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\Label; +use OCA\Deck\Db\Stack; +use OCA\Deck\Service\Importer\ABoardImportService; +use OCP\IUser; +use OCP\IUserManager; + +class DeckJsonService extends ABoardImportService { + public static $name = 'Deck JSON'; + /** @var IUser[] */ + private array $members = []; + private array $tmpCards = []; + + public function __construct( + private IUserManager $userManager, + ) { + } + + public function bootstrap(): void { + $this->validateUsers(); + } + + public function getJsonSchemaPath(): string { + return implode(DIRECTORY_SEPARATOR, [ + __DIR__, + '..', + 'fixtures', + 'config-deckJson-schema.json', + ]); + } + + public function validateUsers(): void { + $relation = $this->getImportService()->getConfig('uidRelation'); + if (empty($relation)) { + return; + } + foreach ($relation as $exportUid => $nextcloudUid) { + if (!is_string($nextcloudUid) && !is_numeric($nextcloudUid)) { + throw new \LogicException('User on setting uidRelation is invalid'); + } + $nextcloudUid = (string) $nextcloudUid; + $this->getImportService()->getConfig('uidRelation')->$exportUid = $this->userManager->get($nextcloudUid); + if (!$this->getImportService()->getConfig('uidRelation')->$exportUid) { + throw new \LogicException('User on setting uidRelation not found: ' . $nextcloudUid); + } + $this->members[$exportUid] = $this->getImportService()->getConfig('uidRelation')->$exportUid; + } + } + + public function mapMember($uid): ?string { + $ownerMap = $this->mapOwner($uid); + $sourceId = ($this->getImportService()->getData()->owner->primaryKey ?? $this->getImportService()->getData()->owner); + + if ($uid === $sourceId && $ownerMap !== $sourceId) { + return $ownerMap; + } + + $uidCandidate = isset($this->members[$uid]) ? $this->members[$uid]?->getUID() ?? null : null; + if ($uidCandidate) { + return $uidCandidate; + } + + if ($this->userManager->userExists($uid)) { + return $uid; + } + + return null; + } + + public function mapOwner(string $uid): string { + $configOwner = $this->getImportService()->getConfig('owner'); + if ($configOwner) { + return $configOwner->getUID(); + } + + return $uid; + } + + public function getCardAssignments(): array { + $assignments = []; + foreach ($this->tmpCards as $sourceCard) { + foreach ($sourceCard->assignedUsers as $idMember) { + $assignment = new Assignment(); + $assignment->setCardId($this->cards[$sourceCard->id]->getId()); + $assignment->setParticipant($this->mapMember($idMember->participant->uid ?? $idMember->participant)); + $assignment->setType($idMember->participant->type); + $assignments[$sourceCard->id][] = $assignment; + } + } + return $assignments; + } + + public function getComments(): array { + // Comments are not implemented in export + return []; + } + + public function getCardLabelAssignment(): array { + $cardsLabels = []; + foreach ($this->tmpCards as $sourceCard) { + foreach ($sourceCard->labels as $label) { + $cardId = $this->cards[$sourceCard->id]->getId(); + $labelId = $this->labels[$label->id]->getId(); + $cardsLabels[$cardId][] = $labelId; + } + } + return $cardsLabels; + } + + public function getBoard(): Board { + $board = $this->getImportService()->getBoard(); + if (empty($this->getImportService()->getData()->title)) { + throw new BadRequestException('Invalid name of board'); + } + $importBoard = $this->getImportService()->getData(); + $board->setTitle($importBoard->title); + $board->setOwner($this->mapOwner($importBoard->owner?->uid ?? $importBoard->owner)); + $board->setColor($importBoard->color); + $board->setArchived($importBoard->archived); + $board->setDeletedAt($importBoard->deletedAt); + $board->setLastModified($importBoard->lastModified); + return $board; + } + + /** + * @return Label[] + */ + public function getLabels(): array { + foreach ($this->getImportService()->getData()->labels as $label) { + $newLabel = new Label(); + $newLabel->setTitle($label->title); + $newLabel->setColor($label->color); + $newLabel->setBoardId($this->getImportService()->getBoard()->getId()); + $newLabel->setLastModified($label->lastModified); + $this->labels[$label->id] = $newLabel; + } + return $this->labels; + } + + /** + * @return Stack[] + */ + public function getStacks(): array { + $return = []; + foreach ($this->getImportService()->getData()->stacks as $index => $source) { + if ($source->title) { + $stack = new Stack(); + $stack->setTitle($source->title); + $stack->setBoardId($this->getImportService()->getBoard()->getId()); + $stack->setOrder($source->order); + $stack->setLastModified($source->lastModified); + $return[$source->id] = $stack; + } + + if (isset($source->cards)) { + foreach ($source->cards as $card) { + $card->stackId = $index; + $this->tmpCards[] = $card; + } + } + } + return $return; + } + + /** + * @return Card[] + */ + public function getCards(): array { + $cards = []; + foreach ($this->tmpCards as $cardSource) { + $card = new Card(); + $card->setTitle($cardSource->title); + $card->setLastModified($cardSource->lastModified); + $card->setLastEditor($cardSource->lastEditor); + $card->setCreatedAt($cardSource->createdAt); + $card->setArchived($cardSource->archived); + $card->setDescription($cardSource->description); + $card->setStackId($this->stacks[$cardSource->stackId]->getId()); + $card->setType('plain'); + $card->setOrder($cardSource->order); + $boardOwner = $this->getBoard()->getOwner(); + $card->setOwner($this->mapOwner(is_string($boardOwner) ? $boardOwner : $boardOwner->getUID())); + $card->setDuedate($cardSource->duedate); + $cards[$cardSource->id] = $card; + } + return $cards; + } + + /** + * @return Acl[] + */ + public function getAclList(): array { + $board = $this->getImportService()->getData(); + $return = []; + foreach ($board->acl as $aclData) { + $acl = new Acl(); + $acl->setBoardId($this->getImportService()->getBoard()->getId()); + $acl->setType($aclData->type); + $participant = $aclData->participant?->primaryKey ?? $aclData->participant; + if ($acl->getType() === Acl::PERMISSION_TYPE_USER) { + $participant = $this->mapMember($participant); + } + $acl->setParticipant($participant); + $acl->setPermissionEdit($aclData->permissionEdit); + $acl->setPermissionShare($aclData->permissionShare); + $acl->setPermissionManage($aclData->permissionManage); + if ($participant) { + $return[] = $acl; + } + } + return $return; + } + + private function replaceUsernames(string $text): string { + foreach ($this->getImportService()->getConfig('uidRelation') as $trello => $nextcloud) { + $text = str_replace($trello, $nextcloud->getUID(), $text); + } + return $text; + } + + public function getBoards(): array { + // Old format has just the raw board data, new one a key boards + $data = $this->getImportService()->getData(); + return array_values((array)($data->boards ?? $data)); + } + + public function reset(): void { + parent::reset(); + $this->tmpCards = []; + } +} diff --git a/lib/Service/Importer/Systems/TrelloJsonService.php b/lib/Service/Importer/Systems/TrelloJsonService.php index 88c4ae23a..a4a3673c1 100644 --- a/lib/Service/Importer/Systems/TrelloJsonService.php +++ b/lib/Service/Importer/Systems/TrelloJsonService.php @@ -397,4 +397,12 @@ private function appendAttachmentsToDescription(\stdClass $trelloCard): void { $trelloCard->desc .= "| [{$name}]({$attachment->url}) | {$attachment->date} |\n"; } } + + public function getBoards(): array { + if ($this->getImportService()->getData()->boards) { + return $this->getImportService()->getData()->boards; + } + + return [$this->getImportService()->getData()]; + } } diff --git a/lib/Service/Importer/fixtures/config-deckJson-schema.json b/lib/Service/Importer/fixtures/config-deckJson-schema.json new file mode 100644 index 000000000..5a8024fad --- /dev/null +++ b/lib/Service/Importer/fixtures/config-deckJson-schema.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "uidRelation": { + "type": "object", + "comment": "Relationship between Trello and Nextcloud usernames", + "example": { + "johndoe": "admin" + } + }, + "owner": { + "type": "string", + "required": true, + "comment": "Nextcloud owner username" + } + } +} diff --git a/tests/data/config-deckJson.json b/tests/data/config-deckJson.json new file mode 100644 index 000000000..ebb67200e --- /dev/null +++ b/tests/data/config-deckJson.json @@ -0,0 +1,7 @@ +{ + "owner": "admin", + "color": "0800fd", + "uidRelation": { + "johndoe": "test-user-1" + } +} diff --git a/tests/data/deck.json b/tests/data/deck.json new file mode 100644 index 000000000..b6744ec94 --- /dev/null +++ b/tests/data/deck.json @@ -0,0 +1,748 @@ +{ + "version": "1.11.0-dev", + "boards": { + "188": { + "id": 188, + "title": "My test board", + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "color": "e0ed31", + "archived": false, + "labels": [ + { + "id": 239, + "title": "L2", + "color": "31CC7C", + "boardId": 188, + "cardId": null, + "lastModified": 1689667435, + "ETag": "63b77251cca5a56fe74a97e4baeab59c" + }, + { + "id": 240, + "title": "L4", + "color": "317CCC", + "boardId": 188, + "cardId": null, + "lastModified": 1689667442, + "ETag": "15dcabeb47583ce5398faaeb65f7a4b6" + }, + { + "id": 241, + "title": "L1", + "color": "FF7A66", + "boardId": 188, + "cardId": null, + "lastModified": 1689667432, + "ETag": "7d58be91f19ebc4f94b352db8c76c056" + }, + { + "id": 242, + "title": "L3", + "color": "F1DB50", + "boardId": 188, + "cardId": null, + "lastModified": 1689667440, + "ETag": "160253b9d33ae0a7a3af90e7d418ba60" + } + ], + "acl": [], + "permissions": { + "PERMISSION_READ": true, + "PERMISSION_EDIT": true, + "PERMISSION_MANAGE": true, + "PERMISSION_SHARE": true + }, + "users": [], + "shared": 0, + "stacks": { + "64": { + "id": 64, + "title": "A", + "boardId": 188, + "deletedAt": 0, + "lastModified": 1689667779, + "order": 999, + "ETag": "ddfd0c27e53d8db94ac5e9aaa021746e", + "cards": [ + { + "id": 114, + "title": "1", + "description": "", + "stackId": 64, + "type": "plain", + "lastModified": 1689667779, + "lastEditor": null, + "createdAt": 1689667569, + "labels": [ + { + "id": 239, + "title": "L2", + "color": "31CC7C", + "boardId": 188, + "cardId": 114, + "lastModified": 1689667435, + "ETag": "63b77251cca5a56fe74a97e4baeab59c" + } + ], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": "2050-07-24T22:00:00+00:00", + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "ddfd0c27e53d8db94ac5e9aaa021746e", + "overdue": 0, + "boardId": 188, + "board": { + "id": 188, + "title": "My test board" + } + }, + { + "id": 115, + "title": "2", + "description": "", + "stackId": 64, + "type": "plain", + "lastModified": 1689667752, + "lastEditor": null, + "createdAt": 1689667572, + "labels": [], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": "2023-07-17T02:00:00+00:00", + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "9a8ed495f7d83f8310ae6291d6dc4624", + "overdue": 3, + "boardId": 188, + "board": { + "id": 188, + "title": "My test board" + } + }, + { + "id": 116, + "title": "3", + "description": "", + "stackId": 64, + "type": "plain", + "lastModified": 1689667760, + "lastEditor": "admin", + "createdAt": 1689667576, + "labels": [], + "assignedUsers": [ + { + "id": 5, + "participant": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "cardId": 116, + "type": 0 + } + ], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "f908c4359e9ca0703f50da2bbe967594", + "overdue": 0, + "boardId": 188, + "board": { + "id": 188, + "title": "My test board" + } + } + ] + }, + "65": { + "id": 65, + "title": "B", + "boardId": 188, + "deletedAt": 0, + "lastModified": 1689667796, + "order": 999, + "ETag": "b97a2b19e1cafc8f95e3f4db71097214", + "cards": [ + { + "id": 117, + "title": "4", + "description": "", + "stackId": 65, + "type": "plain", + "lastModified": 1689667767, + "lastEditor": "admin", + "createdAt": 1689667578, + "labels": [ + { + "id": 239, + "title": "L2", + "color": "31CC7C", + "boardId": 188, + "cardId": 117, + "lastModified": 1689667435, + "ETag": "63b77251cca5a56fe74a97e4baeab59c" + }, + { + "id": 240, + "title": "L4", + "color": "317CCC", + "boardId": 188, + "cardId": 117, + "lastModified": 1689667442, + "ETag": "15dcabeb47583ce5398faaeb65f7a4b6" + }, + { + "id": 241, + "title": "L1", + "color": "FF7A66", + "boardId": 188, + "cardId": 117, + "lastModified": 1689667432, + "ETag": "7d58be91f19ebc4f94b352db8c76c056" + } + ], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "6b20cc46fa5d2e5f65251526b50cc130", + "overdue": 0, + "boardId": 188, + "board": { + "id": 188, + "title": "My test board" + } + }, + { + "id": 118, + "title": "5", + "description": "", + "stackId": 65, + "type": "plain", + "lastModified": 1689667773, + "lastEditor": "admin", + "createdAt": 1689667581, + "labels": [ + { + "id": 239, + "title": "L2", + "color": "31CC7C", + "boardId": 188, + "cardId": 118, + "lastModified": 1689667435, + "ETag": "63b77251cca5a56fe74a97e4baeab59c" + } + ], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "488145982535a91d9ab47db647ecf539", + "overdue": 0, + "boardId": 188, + "board": { + "id": 188, + "title": "My test board" + } + }, + { + "id": 119, + "title": "6", + "description": "# Test description\n\nHello world", + "stackId": 65, + "type": "plain", + "lastModified": 1689667796, + "lastEditor": null, + "createdAt": 1689667583, + "labels": [], + "assignedUsers": [ + { + "id": 6, + "participant": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "cardId": 119, + "type": 0 + } + ], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "b97a2b19e1cafc8f95e3f4db71097214", + "overdue": 0, + "boardId": 188, + "board": { + "id": 188, + "title": "My test board" + } + } + ] + }, + "66": { + "id": 66, + "title": "C", + "boardId": 188, + "deletedAt": 0, + "lastModified": 0, + "order": 999, + "ETag": "cfcd208495d565ef66e7dff9f98764da" + } + }, + "activeSessions": [], + "deletedAt": 0, + "lastModified": 1689667796, + "settings": [], + "ETag": "b97a2b19e1cafc8f95e3f4db71097214" + }, + "189": { + "id": 189, + "title": "Shared board", + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "color": "30b6d8", + "archived": false, + "labels": [ + { + "id": 243, + "title": "Finished", + "color": "31CC7C", + "boardId": 189, + "cardId": null, + "lastModified": 1689667413, + "ETag": "aa71367f6a9a2fc2d47fc46163a30208" + }, + { + "id": 244, + "title": "To review", + "color": "317CCC", + "boardId": 189, + "cardId": null, + "lastModified": 1689667413, + "ETag": "aa71367f6a9a2fc2d47fc46163a30208" + }, + { + "id": 245, + "title": "Action needed", + "color": "FF7A66", + "boardId": 189, + "cardId": null, + "lastModified": 1689667413, + "ETag": "aa71367f6a9a2fc2d47fc46163a30208" + }, + { + "id": 246, + "title": "Later", + "color": "F1DB50", + "boardId": 189, + "cardId": null, + "lastModified": 1689667413, + "ETag": "aa71367f6a9a2fc2d47fc46163a30208" + } + ], + "acl": [ + { + "id": 4, + "participant": { + "primaryKey": "alice", + "uid": "alice", + "displayname": "alice", + "type": 0 + }, + "type": 0, + "boardId": 189, + "permissionEdit": true, + "permissionShare": false, + "permissionManage": false, + "owner": false + }, + { + "id": 5, + "participant": { + "primaryKey": "jane", + "uid": "jane", + "displayname": "jane", + "type": 0 + }, + "type": 0, + "boardId": 189, + "permissionEdit": false, + "permissionShare": true, + "permissionManage": false, + "owner": false + }, + { + "id": 6, + "participant": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 1 + }, + "type": 1, + "boardId": 189, + "permissionEdit": false, + "permissionShare": false, + "permissionManage": true, + "owner": false + } + ], + "permissions": { + "PERMISSION_READ": true, + "PERMISSION_EDIT": true, + "PERMISSION_MANAGE": true, + "PERMISSION_SHARE": true + }, + "users": [], + "shared": 0, + "stacks": { + "61": { + "id": 61, + "title": "ToDo", + "boardId": 189, + "deletedAt": 0, + "lastModified": 1689667537, + "order": 999, + "ETag": "6c315c83f146485e6b2b6fdc24ffa617", + "cards": [ + { + "id": 107, + "title": "Write tests", + "description": "", + "stackId": 61, + "type": "plain", + "lastModified": 1689667521, + "lastEditor": null, + "createdAt": 1689667483, + "labels": [], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 0, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "f0450d41827f55580554c993304c8073", + "overdue": 0, + "boardId": 189, + "board": { + "id": 189, + "title": "Shared board" + } + }, + { + "id": 111, + "title": "Write blog post", + "description": "", + "stackId": 61, + "type": "plain", + "lastModified": 1689667521, + "lastEditor": null, + "createdAt": 1689667518, + "labels": [], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 1, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "f0450d41827f55580554c993304c8073", + "overdue": 0, + "boardId": 189, + "board": { + "id": 189, + "title": "Shared board" + } + }, + { + "id": 112, + "title": "Announce feature", + "description": "", + "stackId": 61, + "type": "plain", + "lastModified": 1689667527, + "lastEditor": null, + "createdAt": 1689667527, + "labels": [], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "1956848c45be91fefc967ee8831ea4cf", + "overdue": 0, + "boardId": 189, + "board": { + "id": 189, + "title": "Shared board" + } + }, + { + "id": 113, + "title": "\ud83c\udf89 Party", + "description": "", + "stackId": 61, + "type": "plain", + "lastModified": 1689667537, + "lastEditor": null, + "createdAt": 1689667537, + "labels": [], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "6c315c83f146485e6b2b6fdc24ffa617", + "overdue": 0, + "boardId": 189, + "board": { + "id": 189, + "title": "Shared board" + } + } + ] + }, + "62": { + "id": 62, + "title": "In progress", + "boardId": 189, + "deletedAt": 0, + "lastModified": 1689667502, + "order": 999, + "ETag": "1498939b8816e6041da80050dacc3ed3", + "cards": [ + { + "id": 108, + "title": "Write feature", + "description": "", + "stackId": 62, + "type": "plain", + "lastModified": 1689667488, + "lastEditor": null, + "createdAt": 1689667488, + "labels": [], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 999, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "d2a8b634cdd96ab5ef48910bbbd715b1", + "overdue": 0, + "boardId": 189, + "board": { + "id": 189, + "title": "Shared board" + } + } + ] + }, + "63": { + "id": 63, + "title": "Done", + "boardId": 189, + "deletedAt": 0, + "lastModified": 1689667518, + "order": 999, + "ETag": "09ba5a39921de760db53bcd56457eea5", + "cards": [ + { + "id": 109, + "title": "Plan feature", + "description": "", + "stackId": 63, + "type": "plain", + "lastModified": 1689667506, + "lastEditor": null, + "createdAt": 1689667493, + "labels": [], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 0, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "193163d8a8acedbfaba196b1f0d65bc8", + "overdue": 0, + "boardId": 189, + "board": { + "id": 189, + "title": "Shared board" + } + }, + { + "id": 110, + "title": "Design feature", + "description": "", + "stackId": 63, + "type": "plain", + "lastModified": 1689667506, + "lastEditor": null, + "createdAt": 1689667502, + "labels": [], + "assignedUsers": [], + "attachments": null, + "attachmentCount": null, + "owner": { + "primaryKey": "admin", + "uid": "admin", + "displayname": "admin", + "type": 0 + }, + "order": 1, + "archived": false, + "duedate": null, + "deletedAt": 0, + "commentsUnread": 0, + "commentsCount": 0, + "ETag": "193163d8a8acedbfaba196b1f0d65bc8", + "overdue": 0, + "boardId": 189, + "board": { + "id": 189, + "title": "Shared board" + } + } + ] + } + }, + "activeSessions": [], + "deletedAt": 0, + "lastModified": 1689667537, + "settings": [], + "ETag": "6c315c83f146485e6b2b6fdc24ffa617" + } + } +} diff --git a/tests/integration/database/TransferOwnershipTest.php b/tests/integration/database/TransferOwnershipTest.php index bba5e12dd..c17d8b7fc 100644 --- a/tests/integration/database/TransferOwnershipTest.php +++ b/tests/integration/database/TransferOwnershipTest.php @@ -277,7 +277,7 @@ public function testTransferSingleBoardAssignment() { // Arrange separate board next to the one being transferred $board = $this->boardService->create('Test 2', self::TEST_USER_1, '000000'); $id = $board->getId(); - $this->boardService->addAcl($id, Acl::PERMISSION_TYPE_USER, self::TEST_USER_1, true, true, true); + // $this->boardService->addAcl($id, Acl::PERMISSION_TYPE_USER, self::TEST_USER_1, true, true, true); $this->boardService->addAcl($id, Acl::PERMISSION_TYPE_GROUP, self::TEST_GROUP, true, true, true); $this->boardService->addAcl($id, Acl::PERMISSION_TYPE_USER, self::TEST_USER_3, false, true, false); $stacks[] = $this->stackService->create('Stack A', $id, 1); diff --git a/tests/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php new file mode 100644 index 000000000..297b823e7 --- /dev/null +++ b/tests/integration/import/ImportExportTest.php @@ -0,0 +1,415 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Deck\Db; + +use OCA\Deck\Command\BoardImport; +use OCA\Deck\Command\UserExport; +use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\CardService; +use OCA\Deck\Service\Importer\BoardImportService; +use OCA\Deck\Service\Importer\Systems\DeckJsonService; +use OCA\Deck\Service\PermissionService; +use OCA\Deck\Service\StackService; +use OCP\App\IAppManager; +use OCP\AppFramework\Db\Entity; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUserManager; +use OCP\Server; +use PHPUnit\Framework\ExpectationFailedException; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @group DB + */ +class ImportExportTest extends \Test\TestCase { + private IDBConnection $connection; + private const TEST_USER1 = 'test-export-user1'; + private const TEST_USER3 = 'test-export-user3'; + private const TEST_USER2 = 'test-export-user2'; + private const TEST_USER4 = 'test-export-user4'; + private const TEST_GROUP1 = 'test-export-group1'; + + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + + $backend = new \Test\Util\User\Dummy(); + \OC_User::useBackend($backend); + Server::get(IUserManager::class)->registerBackend($backend); + $backend->createUser('alice', 'alice'); + $backend->createUser('jane', 'jane'); + $backend->createUser('johndoe', 'johndoe'); + $backend->createUser(self::TEST_USER1, self::TEST_USER1); + $backend->createUser(self::TEST_USER2, self::TEST_USER2); + $backend->createUser(self::TEST_USER3, self::TEST_USER3); + $backend->createUser(self::TEST_USER4, self::TEST_USER4); + // create group + $groupBackend = new \Test\Util\Group\Dummy(); + $groupBackend->createGroup(self::TEST_GROUP1); + $groupBackend->createGroup('group'); + $groupBackend->createGroup('group1'); + $groupBackend->createGroup('group2'); + $groupBackend->createGroup('group3'); + $groupBackend->addToGroup(self::TEST_USER1, 'group'); + $groupBackend->addToGroup(self::TEST_USER2, 'group'); + $groupBackend->addToGroup(self::TEST_USER3, 'group'); + $groupBackend->addToGroup(self::TEST_USER2, 'group1'); + $groupBackend->addToGroup(self::TEST_USER3, 'group2'); + $groupBackend->addToGroup(self::TEST_USER4, 'group3'); + $groupBackend->addToGroup(self::TEST_USER2, self::TEST_GROUP1); + Server::get(IGroupManager::class)->addBackend($groupBackend); + + Server::get(PermissionService::class)->setUserId('admin'); + } + + public function setUp(): void { + parent::setUp(); + + $this->connection = \OCP\Server::get(IDBConnection::class); + $this->cleanDb(); + $this->cleanDb(self::TEST_USER1); + } + + public function testImportOcc() { + $this->importFromFile(__DIR__ . '/../../data/deck.json'); + $this->assertDatabase(); + } + + /** + * This test runs an import, export and another import and + * assert that multiple attempts result in the same data structure + * + * In addition, it asserts that multiple import/export runs result in the same JSON + */ + public function testReimportOcc() { + $this->importFromFile(__DIR__ . '/../../data/deck.json'); + $this->assertDatabase(); + + $tmpExportFile = $this->exportToTemp(); + + // Useful for double checking differences as there is no easy way to compare equal with skipping certain id keys, etag + // self::assertEquals(file_get_contents(__DIR__ . '/../../data/deck.json'), $jsonOutput); + self::assertEquals( + self::writeArrayStructure(array: json_decode(file_get_contents(__DIR__ . '/../../data/deck.json'), true)), + self::writeArrayStructure(array: json_decode(file_get_contents($tmpExportFile), true)) + ); + + // cleanup test database + $this->cleanDb(); + + // Re-import from temporary file + $this->importFromFile($tmpExportFile); + $this->assertDatabase(); + + $tmpExportFile2 = $this->exportToTemp(); + self::assertEquals( + self::writeArrayStructure(array: json_decode(file_get_contents($tmpExportFile), true)), + self::writeArrayStructure(array: json_decode(file_get_contents($tmpExportFile2), true)) + ); + } + + public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version']): string { + $output = ''; + $arrayIsList = array_keys($array) === range(0, count($array) - 1); + foreach ($array as $key => $value) { + $tmpPrefix = $prefix; + if (in_array($key, $skipKeyList)) { + continue; + } + if (is_array($value)) { + if ($key === 'participant' || $key === 'owner') { + $output .= $tmpPrefix . $key . ' => ' . $value['primaryKey'] . PHP_EOL; + continue; + } + $tmpPrefix .= (!$arrayIsList && !is_numeric($key) ? $key : '!!!') . ' => '; + $output .= self::writeArrayStructure($tmpPrefix, $value, $skipKeyList); + } else { + $output .= $tmpPrefix . $key . ' => ' . $value . PHP_EOL; + } + } + return $output; + } + + public function cleanDb(string $owner = 'admin'): void { + $this->connection->executeQuery('DELETE from oc_deck_boards;'); + } + + private function importFromFile(string $filePath): void { + $input = $this->createMock(InputInterface::class); + $input->expects($this->any()) + ->method('getOption') + ->willReturnCallback(function ($arg) use ($filePath) { + return match ($arg) { + 'system' => 'DeckJson', + 'data' => $filePath, + 'config' => __DIR__ . '/../../data/config-trelloJson.json', + }; + }); + $output = $this->createMock(OutputInterface::class); + $importer = self::getFreshService(BoardImport::class); + $application = new Application(); + $importer->setApplication($application); + $importer->run($input, $output); + } + + /** Returns the path of a deck export json */ + private function exportToTemp(): string { + \OCP\Server::get(BoardMapper::class)->flushCache(); + $application = new Application(); + $input = $this->createMock(InputInterface::class); + $input->expects($this->any()) + ->method('getArgument') + ->with('user-id') + ->willReturn('admin'); + $output = new BufferedOutput(); + $exporter = new UserExport( + \OCP\Server::get(IAppManager::class), + self::getFreshService(BoardMapper::class), + self::getFreshService(BoardService::class), + self::getFreshService(StackMapper::class), + self::getFreshService(CardMapper::class), + self::getFreshService(AssignmentMapper::class), + ); + $exporter->setApplication($application); + $exporter->run($input, $output); + $jsonOutput = $output->fetch(); + json_decode($jsonOutput); + self::assertTrue(json_last_error() === JSON_ERROR_NONE); + $tmpExportFile = tempnam('/tmp', 'export'); + file_put_contents($tmpExportFile, $jsonOutput); + return $tmpExportFile; + } + + public function testImport() { + $importer = self::getFreshService(BoardImportService::class); + $deckJsonService = self::getFreshService(DeckJsonService::class); + $deckJsonService->setImportService($importer); + + $importer->setSystem('DeckJson'); + $importer->setImportSystem($deckJsonService); + $importer->setConfigInstance(json_decode(file_get_contents(__DIR__ . '/../../data/config-trelloJson.json'))); + $importer->setData(json_decode(file_get_contents(__DIR__ . '/../../data/deck.json'))); + $importer->import(); + + $this->assertDatabase(); + } + + public function testImportAsOtherUser() { + $importer = self::getFreshService(BoardImportService::class); + $deckJsonService = self::getFreshService(DeckJsonService::class); + $deckJsonService->setImportService($importer); + + $importer->setSystem('DeckJson'); + $importer->setImportSystem($deckJsonService); + $importer->setConfigInstance((object)[ + 'owner' => self::TEST_USER1 + ]); + $importer->setData(json_decode(file_get_contents(__DIR__ . '/../../data/deck.json'))); + $importer->import(); + + $this->assertDatabase(self::TEST_USER1); + } + + public function testImportWithRemap() { + $importer = self::getFreshService(BoardImportService::class); + $deckJsonService = self::getFreshService(DeckJsonService::class); + $deckJsonService->setImportService($importer); + + $importer->setSystem('DeckJson'); + $importer->setImportSystem($deckJsonService); + $importer->setConfigInstance((object)[ + 'owner' => self::TEST_USER1, + 'uidRelation' => (object)[ + 'alice' => self::TEST_USER2, + 'jane' => self::TEST_USER3, + ], + ]); + $importer->setData(json_decode(file_get_contents(__DIR__ . '/../../data/deck.json'))); + $importer->import(); + + $this->assertDatabase(self::TEST_USER1); + $otherUserboards = self::getFreshService(BoardMapper::class)->findAllByUser(self::TEST_USER2); + self::assertCount(1, $otherUserboards); + } + + /** + * @template T + * @param class-string|string $className + * @return T + */ + private function getFreshService(string $className): mixed { + $fresh = \OC::$server->getRegisteredAppContainer('deck')->resolve($className); + self::overwriteService($className, $fresh); + return $fresh; + } + + public function assertDatabase(string $owner = 'admin') { + $permissionService = self::getFreshService(PermissionService::class); + $permissionService->setUserId($owner); + self::getFreshService(BoardService::class); + self::getFreshService(CardService::class); + $boardMapper = self::getFreshService(BoardMapper::class); + $stackMapper = self::getFreshService(StackMapper::class); + $cardMapper = self::getFreshService(CardMapper::class); + + $boards = $boardMapper->findAllByOwner($owner); + $boardNames = array_map(fn ($board) => $board->getTitle(), $boards); + self::assertEquals(2, count($boards)); + + $board = $boards[0]; + self::assertEntity(Board::fromRow([ + 'title' => 'My test board', + 'color' => 'e0ed31', + 'owner' => $owner, + 'lastModified' => 1689667796, + ]), $board); + $boardService = $this->getFreshService(BoardService::class); + $fullBoard = $boardService->find($board->getId(), true); + self::assertEntityInArray(Label::fromParams([ + 'title' => 'L2', + 'color' => '31CC7C', + ]), $fullBoard->getLabels(), true); + + + $stacks = $stackMapper->findAll($board->getId()); + self::assertCount(3, $stacks); + self::assertEntity(Stack::fromRow([ + 'title' => 'A', + 'order' => 999, + 'boardId' => $boards[0]->getId(), + 'lastModified' => 1689667779, + ]), $stacks[0]); + self::assertEntity(Stack::fromRow([ + 'title' => 'B', + 'order' => 999, + 'boardId' => $boards[0]->getId(), + 'lastModified' => 1689667796, + ]), $stacks[1]); + self::assertEntity(Stack::fromRow([ + 'title' => 'C', + 'order' => 999, + 'boardId' => $boards[0]->getId(), + 'lastModified' => 0, + ]), $stacks[2]); + + $cards = $cardMapper->findAll($stacks[0]->getId()); + self::assertEntity(Card::fromRow([ + 'title' => '1', + 'description' => '', + 'type' => 'plain', + 'lastModified' => 1689667779, + 'createdAt' => 1689667569, + 'owner' => $owner, + 'duedate' => new \DateTime('2050-07-24T22:00:00.000000+0000'), + 'order' => 999, + 'stackId' => $stacks[0]->getId(), + ]), $cards[0]); + self::assertEntity(Card::fromRow([ + 'title' => '2', + 'duedate' => new \DateTime('2050-07-24T22:00:00.000000+0000'), + ]), $cards[1], true); + self::assertEntity(Card::fromParams([ + 'title' => '3', + 'duedate' => null, + ]), $cards[2], true); + + $cards = $cardMapper->findAll($stacks[1]->getId()); + self::assertEntity(Card::fromParams([ + 'title' => '6', + 'duedate' => null, + 'description' => "# Test description\n\nHello world", + ]), $cards[2], true); + + // Shared board + $sharedBoard = $boards[1]; + self::assertEntity(Board::fromRow([ + 'title' => 'Shared board', + 'color' => '30b6d8', + 'owner' => $owner, + ]), $sharedBoard, true); + + $stackService = self::getFreshService(StackService::class); + $stacks = $stackService->findAll($board->getId()); + self::assertEntityInArray(Label::fromParams([ + 'title' => 'L2', + 'color' => '31CC7C', + ]), $stacks[0]->getCards()[0]->getLabels(), true); + self::assertEntity(Label::fromParams([ + 'title' => 'L2', + 'color' => '31CC7C', + ]), $stacks[0]->getCards()[0]->getLabels()[0], true); + + $stacks = $stackMapper->findAll($sharedBoard->getId()); + self::assertCount(3, $stacks); + } + + public static function assertEntityInArray(Entity $expected, array $array, bool $checkProperties): void { + $exists = null; + foreach ($array as $entity) { + try { + self::assertEntity($expected, $entity, $checkProperties); + $exists = $entity; + } catch (ExpectationFailedException $e) { + } + } + if ($exists) { + self::assertEntity($expected, $exists, $checkProperties); + } else { + // THis is hard to debug if it fails as the actual diff is not returned but hidden in the above exception + self::assertEquals($expected, $exists); + } + } + + public static function assertEntity(Entity $expected, Entity $actual, bool $checkProperties = false): void { + if ($checkProperties === true) { + $e = clone $expected; + $a = clone $actual; + foreach ($e->getUpdatedFields() as $property => $updated) { + $expectedValue = call_user_func([$e, 'get' . ucfirst($property)]); + $actualValue = call_user_func([$a, 'get' . ucfirst($property)]); + self::assertEquals( + $expectedValue, + $actualValue + ); + } + } else { + $e = clone $expected; + $e->setId(null); + $a = clone $actual; + $a->setId(null); + $e->resetUpdatedFields(); + $a->resetUpdatedFields(); + self::assertEquals($e, $a); + } + } + + public function tearDown(): void { + $this->cleanDb(); + $this->cleanDb(self::TEST_USER1); + parent::tearDown(); + } +} diff --git a/tests/phpunit.integration.xml b/tests/phpunit.integration.xml index f62881f9a..e5534edc8 100644 --- a/tests/phpunit.integration.xml +++ b/tests/phpunit.integration.xml @@ -12,5 +12,8 @@ ./integration/app + + ./integration/import + diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 30338e083..d09bcb153 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -193,6 +193,8 @@ namespace Symfony\Component\Console\Output { class OutputInterface { public const VERBOSITY_VERBOSE = 1; public function writeln($text, int $flat = 0) {} + public function isVerbose(): bool {} + public function isVeryVerbose(): bool {} } } diff --git a/tests/unit/Command/UserExportTest.php b/tests/unit/Command/UserExportTest.php index 81fc49642..1d75a35b9 100644 --- a/tests/unit/Command/UserExportTest.php +++ b/tests/unit/Command/UserExportTest.php @@ -31,12 +31,14 @@ use OCA\Deck\Db\Stack; use OCA\Deck\Db\StackMapper; use OCA\Deck\Service\BoardService; +use OCP\App\IAppManager; use OCP\IGroupManager; use OCP\IUserManager; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class UserExportTest extends \Test\TestCase { + protected $appManager; protected $boardMapper; protected $boardService; protected $stackMapper; @@ -45,10 +47,11 @@ class UserExportTest extends \Test\TestCase { protected $userManager; protected $groupManager; - private $userExport; + private UserExport $userExport; public function setUp(): void { parent::setUp(); + $this->appManager = $this->createMock(IAppManager::class); $this->boardMapper = $this->createMock(BoardMapper::class); $this->boardService = $this->createMock(BoardService::class); $this->stackMapper = $this->createMock(StackMapper::class); @@ -56,7 +59,7 @@ public function setUp(): void { $this->assignedUserMapper = $this->createMock(AssignmentMapper::class); $this->userManager = $this->createMock(IUserManager::class); $this->groupManager = $this->createMock(IGroupManager::class); - $this->userExport = new UserExport($this->boardMapper, $this->boardService, $this->stackMapper, $this->cardMapper, $this->assignedUserMapper, $this->userManager, $this->groupManager); + $this->userExport = new UserExport($this->appManager, $this->boardMapper, $this->boardService, $this->stackMapper, $this->cardMapper, $this->assignedUserMapper, $this->userManager, $this->groupManager); } public function getBoard($id) { @@ -114,5 +117,6 @@ public function testExecute() { ->method('findAll') ->willReturn([]); $result = $this->invokePrivate($this->userExport, 'execute', [$input, $output]); + self::assertEquals(0, $result); } } diff --git a/tests/unit/Service/Importer/BoardImportServiceTest.php b/tests/unit/Service/Importer/BoardImportServiceTest.php index 9e4229529..d367178ac 100644 --- a/tests/unit/Service/Importer/BoardImportServiceTest.php +++ b/tests/unit/Service/Importer/BoardImportServiceTest.php @@ -43,6 +43,7 @@ use OCP\IUser; use OCP\IUserManager; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; class BoardImportServiceTest extends \Test\TestCase { /** @var IDBConnection|MockObject */ @@ -92,7 +93,8 @@ public function setUp(): void { $this->attachmentMapper, $this->cardMapper, $this->commentsManager, - $this->eventDispatcher + $this->eventDispatcher, + $this->createMock(LoggerInterface::class), ); $this->boardImportService->setSystem('trelloJson'); @@ -118,6 +120,9 @@ public function setUp(): void { $this->trelloJsonService ->method('getJsonSchemaPath') ->willReturn($configFile); + $this->trelloJsonService + ->method('getBoards') + ->willReturn([$data]); $this->boardImportService->setImportSystem($this->trelloJsonService); $owner = $this->createMock(IUser::class); @@ -142,6 +147,9 @@ public function setUp(): void { } public function testImportSuccess() { + $this->userManager->method('userExists') + ->willReturn(true); + $this->boardMapper ->expects($this->once()) ->method('insert'); @@ -192,8 +200,7 @@ public function testImportSuccess() { ->expects($this->once()) ->method('insert'); - $actual = $this->boardImportService->import(); - - $this->assertNull($actual); + $this->boardImportService->import(); + self::assertTrue(true); } } diff --git a/tests/unit/Service/Importer/Systems/DeckJsonServiceTest.php b/tests/unit/Service/Importer/Systems/DeckJsonServiceTest.php new file mode 100644 index 000000000..0f0b8aa33 --- /dev/null +++ b/tests/unit/Service/Importer/Systems/DeckJsonServiceTest.php @@ -0,0 +1,86 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\Deck\Service\Importer\Systems; + +use OCA\Deck\Service\Importer\BoardImportService; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Server; +use PHPUnit\Framework\MockObject\MockObject; + +/** + * @group DB + */ +class DeckJsonServiceTest extends \Test\TestCase { + private DeckJsonService $service; + /** @var IURLGenerator|MockObject */ + private $urlGenerator; + /** @var IUserManager|MockObject */ + private $userManager; + /** @var IL10N */ + private $l10n; + public function setUp(): void { + $this->userManager = $this->createMock(IUserManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->l10n = $this->createMock(IL10N::class); + $this->service = new DeckJsonService( + $this->userManager, + $this->urlGenerator, + $this->l10n + ); + } + + public function testGetBoardWithNoName() { + $this->expectExceptionMessage('Invalid name of board'); + $importService = $this->createMock(BoardImportService::class); + $this->service->setImportService($importService); + $this->service->getBoard(); + } + + public function testGetBoardWithSuccess() { + $importService = Server::get(BoardImportService::class); + + $data = json_decode(file_get_contents(__DIR__ . '/../../../../data/deck.json')); + $importService->setData($data); + + $configInstance = json_decode(file_get_contents(__DIR__ . '/../../../../data/config-deckJson.json')); + $importService->setConfigInstance($configInstance); + + $owner = $this->createMock(IUser::class); + $owner + ->method('getUID') + ->willReturn('admin'); + $importService->setConfig('owner', $owner); + + $this->service->setImportService($importService); + + $boards = $this->service->getBoards(); + $importService->setData($boards[0]); + $actual = $this->service->getBoard(); + $this->assertEquals('My test board', $actual->getTitle()); + $this->assertEquals('admin', $actual->getOwner()); + $this->assertEquals('e0ed31', $actual->getColor()); + } +}