diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 9a072988df..05811543b3 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -50,6 +50,7 @@ use OCA\Deck\Sharing\DeckShareProvider; use OCA\Deck\Sharing\Listener; use OCA\Deck\Teams\DeckTeamResourceProvider; +use OCA\Deck\UserMigration\DeckMigrator; use OCA\Text\Event\LoadEditor; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -180,6 +181,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadAdditionalScriptsEvent::class, ResourceAdditionalScriptsListener::class); $context->registerTeamResourceProvider(DeckTeamResourceProvider::class); + + $context->registerUserMigrator(DeckMigrator::class); } public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index 671c0b156b..bcf0bc8eae 100644 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -203,6 +203,18 @@ public function create(int $cardId, string $type, string $data = '') { return $attachment; } + /** + * Apply import side effects to keep attachment behavior consistent with regular create flow. + */ + public function syncAttachmentCreateSideEffects(Attachment $attachment): Attachment { + $cardId = $attachment->getCardId(); + $this->attachmentCacheHelper->clearAttachmentCount($cardId); + $this->addCreator($attachment); + $this->changeHelper->cardChanged($cardId); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_CREATE); + + return $attachment; + } /** * Display the attachment diff --git a/lib/Service/FilesAppService.php b/lib/Service/FilesAppService.php index 1ccf47d14c..f11054dc30 100644 --- a/lib/Service/FilesAppService.php +++ b/lib/Service/FilesAppService.php @@ -30,6 +30,7 @@ use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; +use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; use OCP\Share\IShare; @@ -238,6 +239,98 @@ public function create(Attachment $attachment) { return $attachment; } + /** + * Ensure the file exists in the owner’s Deck attachment folder, link it to the + * Reuse file/share when already present + * + * @param Attachment $attachment + * @param string $content + * @return Attachment + * @throws BadRequestException + * @throws NoPermissionException + * @throws NotFoundException + * @throws StatusException + */ + public function createFromImport(Attachment $attachment, string $content): Attachment { + $fileName = $attachment->getData(); + $this->validateFilename($fileName); + + $ownerId = $attachment->getCreatedBy() ?: $this->userId; + if (!is_string($ownerId) || $ownerId === '') { + throw new StatusException('Could not resolve owner for imported attachment'); + } + + $userFolder = $this->rootFolder->getUserFolder($ownerId); + $attachmentFolderName = $this->configService->getAttachmentFolder($ownerId); + try { + $folder = $userFolder->get($attachmentFolderName); + } catch (NotFoundException) { + $folder = $userFolder->newFolder($attachmentFolderName); + } + if (!$folder instanceof Folder || $folder->isShared()) { + throw new NotFoundException('No target folder found'); + } + + if ($folder->nodeExists($fileName)) { + $node = $folder->get($fileName); + if (!($node instanceof File)) { + throw new StatusException('Attachment target is not a file'); + } + $target = $node; + } else { + $target = $folder->newFile($fileName); + $target->putContent($content); + } + + $cardId = $attachment->getCardId(); + foreach ($this->shareProvider->getSharesByPath($target) as $share) { + if ((int)$share->getSharedWith() === $cardId) { + $attachment->setId((int)$share->getId()); + $attachment->setData($target->getName()); + return $attachment; + } + } + + $this->permissionService->checkPermission( + $this->cardMapper, + $cardId, + Acl::PERMISSION_EDIT, + $ownerId, + true, + true + ); + // Import usually runs in background jobs without request user context. + // Set the actor explicitly so DeckShareProvider permission checks evaluate correctly. + $this->permissionService->setUserId($ownerId); + + $share = $this->shareManager->newShare(); + $share->setNode($target); + $share->setShareType(IShare::TYPE_DECK); + $share->setSharedWith((string)$cardId); + $share->setPermissions(Constants::PERMISSION_READ); + $share->setSharedBy($ownerId); + $share->setShareOwner($ownerId); + + try { + $share = $this->shareManager->createShare($share); + } catch (GenericShareException) { + $share = null; + foreach ($this->shareProvider->getSharesByPath($target) as $existing) { + if ((int)$existing->getSharedWith() === $cardId) { + $share = $existing; + break; + } + } + if ($share === null) { + throw new StatusException('Could not create deck share for imported attachment'); + } + } + + $attachment->setId((int)$share->getId()); + $attachment->setData($target->getName()); + return $attachment; + } + /** * @return array * @throws StatusException diff --git a/lib/Service/Importer/ABoardImportService.php b/lib/Service/Importer/ABoardImportService.php index 7726f707f4..b0b1576d6e 100644 --- a/lib/Service/Importer/ABoardImportService.php +++ b/lib/Service/Importer/ABoardImportService.php @@ -74,6 +74,8 @@ abstract public function getCardLabelAssignment(): array; */ abstract public function getComments(): array; + abstract public function importAttachments(): void; + /** @return Label[] */ abstract public function getLabels(): array; diff --git a/lib/Service/Importer/BoardImportService.php b/lib/Service/Importer/BoardImportService.php index b47f011db8..2bb17dee55 100644 --- a/lib/Service/Importer/BoardImportService.php +++ b/lib/Service/Importer/BoardImportService.php @@ -23,7 +23,8 @@ use OCA\Deck\Event\BoardImportGetAllowedEvent; use OCA\Deck\Exceptions\ConflictException; use OCA\Deck\NotFoundException; -use OCA\Deck\Service\FileService; +use OCA\Deck\Service\AttachmentService; +use OCA\Deck\Service\FilesAppService; use OCA\Deck\Service\Importer\Systems\DeckJsonService; use OCA\Deck\Service\Importer\Systems\TrelloApiService; use OCA\Deck\Service\Importer\Systems\TrelloJsonService; @@ -73,6 +74,7 @@ public function __construct( private ICommentsManager $commentsManager, private IEventDispatcher $eventDispatcher, private LoggerInterface $logger, + private AttachmentService $attachmentService, ) { $this->board = new Board(); $this->disableCommentsEvents(); @@ -125,6 +127,7 @@ public function import(): void { $this->importLabels(); $this->importStacks(); $this->importCards(); + $this->getImportSystem()->importAttachments(); $this->assignCardsToLabels(); $this->importComments(); $this->importCardAssignments(); @@ -319,11 +322,23 @@ public function assignCardsToLabels(): void { } } + /** + * Insert parsed comments by card and remap parent IDs from source to imported comments. + * + * @return void + */ public function importComments(): void { - $allComments = $this->getImportSystem()->getComments(); - foreach ($allComments as $cardId => $comments) { + $commentsByCard = $this->getImportSystem()->getComments(); + $sourceToImportedCommentId = []; + foreach ($commentsByCard as $cardId => $comments) { foreach ($comments as $commentId => $comment) { + $metaData = $comment->getMetaData() ?? []; + $sourceParentId = $comment->getParentId(); + $comment->setParentId($sourceToImportedCommentId[$sourceParentId] ?? '0'); + $this->insertComment((int)$cardId, $comment); + $sourceId = (string)($metaData['deckImportSourceId'] ?? $commentId); + $sourceToImportedCommentId[$sourceId] = $comment->getId(); $this->getImportSystem()->updateComment((int)$cardId, $commentId, $comment); } } @@ -369,20 +384,16 @@ public function importCardAssignments(): void { } public function insertAttachment(Attachment $attachment, string $content): Attachment { - $service = Server::get(FileService::class); - $folder = $service->getFolder($attachment); - - if ($folder->fileExists($attachment->getData())) { - $attachment = $this->attachmentMapper->findByData($attachment->getCardId(), $attachment->getData()); - throw new ConflictException('File already exists.', $attachment); - } - - $target = $folder->newFile($attachment->getData()); - $target->putContent($content); + $attachment->setType('file'); + $attachment->setLastModified(time()); + $attachment->setCreatedAt(time()); - $attachment = $this->attachmentMapper->insert($attachment); + /** @var FilesAppService $fileService */ + $fileService = $this->attachmentService->getService('file'); + $fileService->createFromImport($attachment, $content); + $fileService->extendData($attachment); + $this->attachmentService->syncAttachmentCreateSideEffects($attachment); - $service->extendData($attachment); return $attachment; } diff --git a/lib/Service/Importer/Systems/DeckJsonService.php b/lib/Service/Importer/Systems/DeckJsonService.php index b378cae8da..99dcb36bac 100644 --- a/lib/Service/Importer/Systems/DeckJsonService.php +++ b/lib/Service/Importer/Systems/DeckJsonService.php @@ -11,12 +11,14 @@ use OCA\Deck\BadRequestException; use OCA\Deck\Db\Acl; use OCA\Deck\Db\Assignment; +use OCA\Deck\Db\Attachment; 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\Comments\IComment; +use OCP\Comments\ICommentsManager; use OCP\IUser; use OCP\IUserManager; @@ -94,6 +96,9 @@ public function mapOwner(string $uid): string { public function getCardAssignments(): array { $assignments = []; foreach ($this->tmpCards as $sourceCard) { + if (!property_exists($sourceCard, 'assignedUsers') || !is_iterable($sourceCard->assignedUsers)) { + continue; + } foreach ($sourceCard->assignedUsers as $idMember) { $assignment = new Assignment(); $assignment->setCardId($this->cards[$sourceCard->id]->getId()); @@ -105,24 +110,105 @@ public function getCardAssignments(): array { return $assignments; } + /** + * Parse comments from source cards and return them as an array of comments by card ID. + * + */ public function getComments(): array { $comments = []; foreach ($this->tmpCards as $sourceCard) { - if (!property_exists($sourceCard, 'comments')) { + if (!property_exists($sourceCard, 'comments') || !isset($this->cards[$sourceCard->id])) { + continue; + } + $targetCardId = $this->cards[$sourceCard->id]->getId(); + if (!is_iterable($sourceCard->comments)) { continue; } - $commentsOriginal = $sourceCard->comments; - foreach ($commentsOriginal as $commentOriginal) { + foreach ($sourceCard->comments as $commentOriginal) { + $commentId = (string)($commentOriginal->id ?? ''); + if ($commentId === '') { + continue; + } + $parentId = (string)($commentOriginal->parentId ?? '0'); + [$actorType, $actorId] = $this->resolveCommentActor($commentOriginal); $comment = new Comment(); - $comment->setActor($commentOriginal->actorType, $commentOriginal->actorId) - ->setMessage($commentOriginal->message)->setCreationDateTime(\DateTime::createFromFormat('Y-m-d\TH:i:sP', $commentOriginal->creationDateTime)); - $comments[$this->cards[$sourceCard->id]->getId()][$commentOriginal->id] = $comment; + $comment->setActor($actorType, $actorId) + ->setParentId($parentId) + ->setMessage($commentOriginal->message) + ->setCreationDateTime(\DateTime::createFromFormat('Y-m-d\TH:i:sP', $commentOriginal->creationDateTime)); + $comment->setMetaData([ + 'deckImportSourceId' => $commentId, + 'deckImportParentId' => $parentId, + ]); + $comments[$targetCardId][$commentId] = $comment; } } /** @var array> */ return $comments; } + public function importAttachments(): void { + foreach ($this->tmpCards as $sourceCard) { + $this->importAttachmentsForCard($sourceCard); + } + } + + /** + * @return array{0: string, 1: string} + */ + private function resolveCommentActor(object $commentOriginal): array { + $actorType = (string)($commentOriginal->actorType ?? 'users'); + $actorId = $commentOriginal->actorId ?? null; + + if (!is_string($actorId) || $actorId === '') { + return [ICommentsManager::DELETED_USER, ICommentsManager::DELETED_USER]; + } + + if ($actorType === 'users' && !$this->userManager->userExists($actorId)) { + return [ICommentsManager::DELETED_USER, ICommentsManager::DELETED_USER]; + } + + return [$actorType, $actorId]; + } + + private function importAttachmentsForCard(object $sourceCard): void { + if (!property_exists($sourceCard, 'attachments') || !isset($this->cards[$sourceCard->id])) { + return; + } + + $targetCardId = $this->cards[$sourceCard->id]->getId(); + foreach ($sourceCard->attachments as $sourceAttachment) { + if (($sourceAttachment->type ?? null) !== 'file') { + continue; + } + if (!isset($sourceAttachment->data) || !isset($sourceAttachment->contentBase64)) { + continue; + } + + $attachment = new Attachment(); + $attachment->setCardId($targetCardId); + $attachment->setType('file'); + $attachment->setData((string)$sourceAttachment->data); + $attachment->setCreatedBy( + $this->mapMember($sourceAttachment->createdBy ?? $this->getImportService()->getData()->owner) + ?? $this->mapOwner($this->getImportService()->getData()->owner) + ); + $attachment->setCreatedAt((int)($sourceAttachment->createdAt ?? time())); + $attachment->setLastModified((int)($sourceAttachment->lastModified ?? time())); + + $content = base64_decode((string)$sourceAttachment->contentBase64, true); + if ($content === false) { + continue; + } + + try { + $this->getImportService()->insertAttachment($attachment, $content); + } catch (\Throwable $e) { + continue; + } + } + } + public function getCardLabelAssignment(): array { $cardsLabels = []; foreach ($this->tmpCards as $sourceCard) { @@ -184,7 +270,7 @@ public function getStacks(): array { if (isset($source->cards)) { foreach ($source->cards as $card) { - $card->stackId = $index; + $card->stackId = $source->id; $this->tmpCards[] = $card; } } diff --git a/lib/Service/Importer/Systems/TrelloJsonService.php b/lib/Service/Importer/Systems/TrelloJsonService.php index 142ad242ff..88cd5e930a 100644 --- a/lib/Service/Importer/Systems/TrelloJsonService.php +++ b/lib/Service/Importer/Systems/TrelloJsonService.php @@ -163,6 +163,9 @@ function (\stdClass $a) use ($trelloCard) { return $comments; } + public function importAttachments(): void { + } + private function sortComments(array $comments): array { $comparison = function (\stdClass $a, \stdClass $b): int { if ($a->date === $b->date) { diff --git a/lib/Service/ShareFileAttachmentExportService.php b/lib/Service/ShareFileAttachmentExportService.php new file mode 100644 index 0000000000..2c68a39da1 --- /dev/null +++ b/lib/Service/ShareFileAttachmentExportService.php @@ -0,0 +1,75 @@ +> + */ + public function exportCardAttachments(int $cardId, string $fallbackUserId): array { + $formattedAttachments = []; + foreach ($this->getShareFileAttachments($cardId) as $share) { + $shareAttachment = $this->serializeShareAttachment($share, $fallbackUserId); + if ($shareAttachment !== null) { + $formattedAttachments[] = $shareAttachment; + } + } + + return $formattedAttachments; + } + + /** + * @return array> + */ + private function getShareFileAttachments(int $cardId): array { + $qb = $this->dbConnection->getQueryBuilder(); + $qb->select('id', 'uid_owner', 'uid_initiator', 'file_source', 'stime') + ->from('share') + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(12))) + ->andWhere($qb->expr()->eq('share_with', $qb->createNamedParameter((string)$cardId))) + ->andWhere($qb->expr()->eq('item_type', $qb->createNamedParameter('file'))); + return $qb->executeQuery()->fetchAllAssociative(); + } + + /** + * @param array $share + * + * @return array|null + */ + private function serializeShareAttachment(array $share, string $fallbackUserId): ?array { + try { + $nodes = $this->rootFolder->getById((int)$share['file_source']); + $node = $nodes[0] ?? null; + if ($node === null || !method_exists($node, 'getContent') || !method_exists($node, 'getName')) { + return null; + } + + return [ + 'type' => 'file', + 'data' => (string)$node->getName(), + 'createdBy' => (string)($share['uid_initiator'] ?? $share['uid_owner'] ?? $fallbackUserId), + 'createdAt' => (int)($share['stime'] ?? time()), + 'lastModified' => method_exists($node, 'getMTime') ? (int)$node->getMTime() : (int)($share['stime'] ?? time()), + 'contentBase64' => base64_encode($node->getContent()), + ]; + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/lib/UserMigration/DeckMigrator.php b/lib/UserMigration/DeckMigrator.php new file mode 100644 index 0000000000..7b4caf2545 --- /dev/null +++ b/lib/UserMigration/DeckMigrator.php @@ -0,0 +1,271 @@ +getUID(); + $this->boardService->setUserId($uid); + $this->permissionService->setUserId($uid); + + try { + $exportData = $this->buildExportData($uid); + $jsonData = json_encode($exportData, self::JSON_OPTIONS); + + $exportDestination->addFileContents( + self::FILE_BOARDS, + $jsonData + ); + } catch (\Throwable $e) { + throw new DeckMigratorException($e->getMessage(), 0, $e); + } + } + + /** + * {@inheritDoc} + */ + public function import( + IUser $user, + IImportSource $importSource, + OutputInterface $output, + ): void { + if (!$this->shouldImport($importSource)) { + return; + } + + $this->permissionService->setUserId($user->getUID()); + if (!$this->permissionService->canCreate()) { + $output->writeln('Deck import failed: user is not allowed to create boards.'); + return; + } + + try { + $data = $this->readImportData($importSource); + if (empty($data['boards'])) { + return; + } + $this->configureImportService($user->getUID(), $data); + $this->boardImportService->import(); + } catch (\Throwable $e) { + throw new DeckMigratorException($e->getMessage(), 0, $e); + } + } + + private function buildExportData(string $uid): array { + $boards = $this->boardMapper->findAllByUser($uid); + $exportData = ['boards' => []]; + + foreach ($boards as $board) { + // skip if the board is deleted (to align with the export service) + if ($board->getDeletedAt() > 0) { + continue; + } + $boardWithStacksAndCards = $this->boardService->export($board->getId()); + $this->appendArchivedCards($boardWithStacksAndCards); + $exportData['boards'][] = $this->serializeBoard($boardWithStacksAndCards, $uid); + } + + return $exportData; + } + + private function serializeBoard(object $board, string $uid): array { + $boardData = $board->jsonSerialize(); + $serializedStacks = []; + foreach ($board->getStacks() ?? [] as $stack) { + $stackData = $stack->jsonSerialize(); + $serializedCards = []; + foreach ($stack->getCards() ?? [] as $card) { + $serializedCards[] = $this->serializeCard($card, $uid); + } + $stackData['cards'] = $serializedCards; + $serializedStacks[] = $stackData; + } + $boardData['stacks'] = $serializedStacks; + + return $boardData; + } + + private function appendArchivedCards(object $board): void { + $stacks = $board->getStacks() ?? []; + if (count($stacks) === 0) { + return; + } + + $stackIds = array_map(static fn ($stack) => $stack->getId(), $stacks); + $archivedCardsByStack = $this->cardMapper->findAllArchivedForStacks($stackIds); + + foreach ($stacks as $stack) { + $activeCards = $stack->getCards() ?? []; + $archivedCards = $archivedCardsByStack[$stack->getId()] ?? []; + if (count($archivedCards) === 0) { + continue; + } + $stack->setCards(array_merge($activeCards, $archivedCards)); + } + } + + private function serializeCard(object $card, string $uid): array { + $cardId = $card->getId(); + + $cardData = $card->jsonSerialize(); + $cardData['comments'] = (isset($cardData['comments']) && is_array($cardData['comments']) && $cardData['comments'] !== []) + ? $cardData['comments'] + : $this->serializeCardComments($cardId); + $cardData['attachments'] = (isset($cardData['attachments']) && is_array($cardData['attachments']) && $cardData['attachments'] !== []) + ? $cardData['attachments'] + : $this->shareFileAttachmentExportService->exportCardAttachments($cardId, $uid); + + return $cardData; + } + + private function serializeCardComments(int $cardId): array { + $comments = iterator_to_array($this->commentsManager->getForObject( + Application::COMMENT_ENTITY_TYPE, + (string)$cardId + )); + usort($comments, static function ($firstComment, $secondComment): int { + return ((int)$firstComment->getId()) <=> ((int)$secondComment->getId()); + }); + + $formattedComments = []; + foreach ($comments as $comment) { + $formattedComments[] = [ + 'id' => $comment->getId(), + 'parentId' => $comment->getParentId(), + 'actorType' => $comment->getActorType(), + 'actorId' => $comment->getActorId(), + 'message' => $comment->getMessage(), + 'creationDateTime' => $comment->getCreationDateTime()->format(\DateTime::ATOM), + 'objectType' => $comment->getObjectType(), + 'objectId' => $comment->getObjectId(), + 'verb' => $comment->getVerb(), + ]; + } + + return $formattedComments; + } + + private function shouldImport(IImportSource $importSource): bool { + return $importSource->getMigratorVersion($this->getId()) !== null; + } + + private function readImportData(IImportSource $importSource): array { + $fileContents = $importSource->getFileContents(self::FILE_BOARDS); + $data = json_decode( + $fileContents, + true, + self::JSON_DEPTH, + self::JSON_OPTIONS + ); + + if ($data === null) { + throw new \Exception('Failed to parse JSON: ' . json_last_error_msg()); + } + + return $data; + } + + private function configureImportService(string $userId, array $data): void { + $this->boardImportService->setSystem('DeckJson'); + $this->boardImportService->setConfigInstance((object)[ + 'owner' => $userId, + 'uidRelation' => new \stdClass(), + ]); + $this->boardImportService->setData(json_decode( + json_encode(['boards' => $data['boards']]), + false, + self::JSON_DEPTH, + self::JSON_OPTIONS + )); + } + + /** + * {@inheritDoc} + */ + public function getId(): string { + return 'deck'; + } + + /** + * {@inheritDoc} + */ + public function getDisplayName(): string { + return $this->l10n->t('Deck'); + } + + /** + * {@inheritDoc} + */ + public function getDescription(): string { + return $this->l10n->t('All boards owned by you including stacks, cards, labels, assignments, and comments'); + } +} diff --git a/lib/UserMigration/DeckMigratorException.php b/lib/UserMigration/DeckMigratorException.php new file mode 100644 index 0000000000..1cc1c7d6a4 --- /dev/null +++ b/lib/UserMigration/DeckMigratorException.php @@ -0,0 +1,15 @@ +cardMapper = $this->createMock(CardMapper::class); $this->assignmentMapper = $this->createMock(AssignmentMapper::class); $this->attachmentMapper = $this->createMock(AttachmentMapper::class); + $this->attachmentService = $this->createMock(AttachmentService::class); $this->commentsManager = $this->createMock(ICommentsManager::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->boardImportService = new BoardImportService( @@ -96,6 +100,7 @@ public function setUp(): void { $this->commentsManager, $this->eventDispatcher, $this->createMock(LoggerInterface::class), + $this->attachmentService, ); $this->boardImportService->setSystem('trelloJson'); diff --git a/tests/unit/UserMigration/DeckMigratorTest.php b/tests/unit/UserMigration/DeckMigratorTest.php new file mode 100644 index 0000000000..4fcaa37da5 --- /dev/null +++ b/tests/unit/UserMigration/DeckMigratorTest.php @@ -0,0 +1,212 @@ +boardMapper = $this->createMock(BoardMapper::class); + $this->stackMapper = $this->createMock(StackMapper::class); + $this->cardMapper = $this->createMock(CardMapper::class); + $this->labelMapper = $this->createMock(LabelMapper::class); + $this->aclMapper = $this->createMock(AclMapper::class); + $this->assignmentMapper = $this->createMock(AssignmentMapper::class); + $this->attachmentMapper = $this->createMock(AttachmentMapper::class); + $this->commentsManager = $this->createMock(ICommentsManager::class); + $this->appData = $this->createMock(IAppData::class); + $this->shareFileAttachmentExportService = $this->createMock(ShareFileAttachmentExportService::class); + $this->boardService = $this->createMock(BoardService::class); + $this->boardImportService = $this->createMock(BoardImportService::class); + $this->permissionService = $this->createMock(PermissionService::class); + + $this->migrator = new DeckMigrator( + $this->createMock(IL10N::class), + $this->boardMapper, + $this->stackMapper, + $this->cardMapper, + $this->labelMapper, + $this->aclMapper, + $this->assignmentMapper, + $this->attachmentMapper, + $this->commentsManager, + $this->appData, + $this->shareFileAttachmentExportService, + $this->boardService, + $this->boardImportService, + $this->permissionService, + ); + } + + public function testExportWritesBoardsJson(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin'); + + $board = new Board(); + $board->setId(42); + $board->setTitle('Board A'); + + $this->boardMapper->expects($this->once()) + ->method('findAllByUser') + ->with('admin') + ->willReturn([$board]); + $this->boardService->expects($this->once()) + ->method('setUserId') + ->with('admin'); + $this->permissionService->expects($this->once()) + ->method('setUserId') + ->with('admin'); + $this->boardService->expects($this->once()) + ->method('export') + ->with(42) + ->willReturn($board); + + $destination = $this->createMock(IExportDestination::class); + $destination->expects($this->once()) + ->method('addFileContents') + ->with( + 'boards.json', + $this->callback(static function (string $json): bool { + $decoded = json_decode($json, true); + return is_array($decoded) && isset($decoded['boards']) && count($decoded['boards']) === 1; + }) + ); + + $this->migrator->export($user, $destination, $this->createMock(OutputInterface::class)); + } + + public function testExportSkipsDeletedBoards(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin'); + + $deletedBoard = new Board(); + $deletedBoard->setId(5202); + $deletedBoard->setTitle('Deleted board'); + $deletedBoard->setDeletedAt(time()); + + $this->boardMapper->expects($this->once()) + ->method('findAllByUser') + ->with('admin') + ->willReturn([$deletedBoard]); + $this->boardService->expects($this->once()) + ->method('setUserId') + ->with('admin'); + $this->permissionService->expects($this->once()) + ->method('setUserId') + ->with('admin'); + $this->boardService->expects($this->never()) + ->method('export'); + + $destination = $this->createMock(IExportDestination::class); + $destination->expects($this->once()) + ->method('addFileContents') + ->with( + 'boards.json', + $this->callback(static function (string $json): bool { + $decoded = json_decode($json, true); + return is_array($decoded) && isset($decoded['boards']) && count($decoded['boards']) === 0; + }) + ); + + $this->migrator->export($user, $destination, $this->createMock(OutputInterface::class)); + } + + public function testImportSkipsWhenNoVersion(): void { + $source = $this->createMock(IImportSource::class); + $source->method('getMigratorVersion')->with('deck')->willReturn(null); + + $this->boardImportService->expects($this->never())->method('import'); + + $this->migrator->import( + $this->createMock(IUser::class), + $source, + $this->createMock(OutputInterface::class), + ); + } + + public function testImportConfiguresServiceAndImports(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $source = $this->createMock(IImportSource::class); + $source->method('getMigratorVersion')->with('deck')->willReturn(1); + $source->method('getFileContents')->with('boards.json')->willReturn('{"boards":[{"id":1,"title":"Board A","stacks":[]}]}'); + + $this->permissionService->expects($this->once()) + ->method('setUserId') + ->with('alice'); + $this->permissionService->expects($this->once()) + ->method('canCreate') + ->willReturn(true); + + $this->boardImportService->expects($this->once())->method('setSystem')->with('DeckJson'); + $this->boardImportService->expects($this->once()) + ->method('setConfigInstance') + ->with($this->callback(static function (\stdClass $config): bool { + return isset($config->owner, $config->uidRelation) && $config->owner === 'alice'; + })); + $this->boardImportService->expects($this->once()) + ->method('setData') + ->with($this->callback(static function (\stdClass $data): bool { + return isset($data->boards) && is_array($data->boards) && count($data->boards) === 1; + })); + $this->boardImportService->expects($this->once())->method('import'); + + $this->migrator->import($user, $source, $this->createMock(OutputInterface::class)); + } +}