From 049492bae240ab7986438056a45005ddfe88ca5e Mon Sep 17 00:00:00 2001 From: samin-z Date: Wed, 12 Aug 2026 17:17:00 +0200 Subject: [PATCH 1/3] allow creating team attached boards via API Signed-off-by: samin-z --- appinfo/routes.php | 4 +- lib/Controller/BoardApiController.php | 11 ++++ lib/Controller/BoardController.php | 5 ++ lib/Db/Board.php | 4 ++ .../Version11002Date20260812120000.php | 41 +++++++++++++++ lib/Service/BoardService.php | 51 +++++++++++++++++++ tests/unit/Service/BoardServiceTest.php | 1 + 7 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 lib/Migration/Version11002Date20260812120000.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 1a64b7eb84..ede98857a5 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -19,6 +19,7 @@ // boards ['name' => 'board#index', 'url' => '/boards', 'verb' => 'GET'], ['name' => 'board#create', 'url' => '/boards', 'verb' => 'POST'], + ['name' => 'board#createForTeam', 'url' => '/boards/team', 'verb' => 'POST'], ['name' => 'board#read', 'url' => '/boards/{boardId}', 'verb' => 'GET'], ['name' => 'board#update', 'url' => '/boards/{boardId}', 'verb' => 'PUT'], ['name' => 'board#delete', 'url' => '/boards/{boardId}', 'verb' => 'DELETE'], @@ -78,8 +79,9 @@ // api ['name' => 'board_api#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'], - ['name' => 'board_api#get', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'GET'], ['name' => 'board_api#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'], + ['name' => 'board_api#createForTeam', 'url' => '/api/v{apiVersion}/boards/team', 'verb' => 'POST'], + ['name' => 'board_api#get', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'GET'], ['name' => 'board_api#delete', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'DELETE'], ['name' => 'board_api#update', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'PUT'], ['name' => 'board_api#undo_delete', 'url' => '/api/v{apiVersion}/boards/{boardId}/undo_delete', 'verb' => 'POST'], diff --git a/lib/Controller/BoardApiController.php b/lib/Controller/BoardApiController.php index 4953c1b989..a27498dda5 100644 --- a/lib/Controller/BoardApiController.php +++ b/lib/Controller/BoardApiController.php @@ -86,6 +86,17 @@ public function create(string $title, string $color): DataResponse { return new DataResponse($board, HTTP::STATUS_OK); } + /** + * Create a board attached to a team (circle). + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[CORS] + public function createForTeam(string $title, string $teamId, ?string $color = null): DataResponse { + $board = $this->boardService->createForTeam($title, $this->userId, $color, $teamId); + return new DataResponse($board, HTTP::STATUS_OK); + } + /** * Update a board with the specified boardId, title and color, and archived state. */ diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index 5c43ab7b12..459fafa283 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -50,6 +50,11 @@ public function create(string $title, string $color): Board { return $this->boardService->create($title, $this->userId, $color); } + #[NoAdminRequired] + public function createForTeam(string $title, string $teamId, ?string $color = null): Board { + return $this->boardService->createForTeam($title, $this->userId, $color, $teamId); + } + #[NoAdminRequired] public function update(int $id, string $title, string $color, bool $archived): Board { return $this->boardService->update($id, $title, $color, $archived); diff --git a/lib/Db/Board.php b/lib/Db/Board.php index a0663dbd3d..9714a0117f 100644 --- a/lib/Db/Board.php +++ b/lib/Db/Board.php @@ -22,6 +22,8 @@ * @method void setLastModified(int $lastModified) * @method string getOwner() * @method void setOwner(string $owner) + * @method string|null getTeamId() + * @method void setTeamId(?string $teamId) * @method string getColor() * @method void setColor(string $color) * @method void setShareToken(string $shareToken) @@ -32,6 +34,7 @@ class Board extends RelationalEntity { protected $title; protected $owner; + protected $teamId = null; protected $color; protected $archived = false; /** @var Label[]|null */ @@ -58,6 +61,7 @@ public function __construct() { $this->addType('lastModified', 'integer'); $this->addType('shareToken', 'string'); $this->addType('externalId', 'integer'); + $this->addType('teamId', 'string'); $this->addRelation('labels'); $this->addRelation('acl'); $this->addRelation('shared'); diff --git a/lib/Migration/Version11002Date20260812120000.php b/lib/Migration/Version11002Date20260812120000.php new file mode 100644 index 0000000000..ab9996dff7 --- /dev/null +++ b/lib/Migration/Version11002Date20260812120000.php @@ -0,0 +1,41 @@ +hasTable('deck_boards')) { + return null; + } + + $table = $schema->getTable('deck_boards'); + if (!$table->hasColumn('team_id')) { + $table->addColumn('team_id', 'string', [ + 'notnull' => false, + 'length' => 64, + 'default' => null, + ]); + } + + if (!$table->hasIndex('deck_boards_team_id')) { + $table->addIndex(['team_id'], 'deck_boards_team_id'); + } + + return $schema; + } +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 1610352ae3..2edb86a0b4 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -82,6 +82,7 @@ public function __construct( private IUserManager $userManager, private ISecureRandom $random, private ConfigService $configService, + private CirclesService $circlesService, private ?string $userId, ) { } @@ -228,6 +229,56 @@ public function create(string $title, string $userId, string $color): Board { return $board; } + /** + * Create a board with the current user as owner and attach it to a team + * + * @throws BadRequestException + * @throws NoPermissionException + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function createForTeam(string $title, string $userId, ?string $color, string $teamId): Board { + if ($color === null || $color === '') { + $color = sprintf('%06x', random_int(0, 0xffffff)); + } elseif (str_starts_with($color, '#')) { + $color = substr($color, 1); + } + + $this->boardServiceValidator->check(compact('title', 'userId', 'color')); + + if ($teamId === '') { + throw new BadRequestException('teamId must not be empty'); + } + + if (!$this->circlesService->isCirclesEnabled()) { + throw new BadRequestException('Circles/Teams app is not enabled'); + } + + if ($this->circlesService->getCircle($teamId) === null) { + throw new BadRequestException('Team not found'); + } + + if (!$this->circlesService->isUserInCircle($teamId, $userId)) { + throw new NoPermissionException('You must be a member of the team to create a team board'); + } + + $board = $this->create($title, $userId, $color); + $board->setTeamId($teamId); + $board = $this->boardMapper->update($board); + + // give edit access to the team members + $this->addAcl( + $board->getId(), + Acl::PERMISSION_TYPE_CIRCLE, + $teamId, + true, + false, + false, + ); + + return $this->find($board->getId()); + } + /** * @throws DoesNotExistException * @throws NoPermissionException diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index e28a133a73..c4e430cb54 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -151,6 +151,7 @@ public function setUp(): void { $this->userManager, $this->createMock(SecureRandom::class), $this->createMock(ConfigService::class), + $this->createMock(CirclesService::class), $this->userId ); From 5c49b027970627f546a4320c7a10d7a9bbea7085 Mon Sep 17 00:00:00 2001 From: samin-z Date: Thu, 13 Aug 2026 16:20:32 +0200 Subject: [PATCH 2/3] on user leave or team delete, transfer ownership or delete board Signed-off-by: samin-z --- lib/AppInfo/Application.php | 2 + lib/Db/BoardMapper.php | 14 ++++ lib/Listeners/ParticipantCleanupListener.php | 40 ++++++++--- lib/Service/CirclesService.php | 42 +++++++++++ lib/Service/TeamBoardService.php | 73 ++++++++++++++++++++ 5 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 lib/Service/TeamBoardService.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 51efc0a480..fa1df26ff0 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -9,6 +9,7 @@ use Exception; use OCA\Circles\Events\CircleDestroyedEvent; +use OCA\Circles\Events\RemovingCircleMemberEvent; use OCA\Deck\Capabilities; use OCA\Deck\Collaboration\Resources\ResourceProvider; use OCA\Deck\Collaboration\Resources\ResourceProviderCard; @@ -170,6 +171,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, ParticipantCleanupListener::class); $context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class); + $context->registerEventListener(RemovingCircleMemberEvent::class, ParticipantCleanupListener::class); $context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class); // Event listening for realtime updates via notify_push diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index d9d7d2c4b8..adaf2962e1 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -306,6 +306,20 @@ public function findAllByOwner(string $userId, ?int $limit = null, ?int $offset return $this->findEntities($qb); } + /** + * Find all board with the team_id set to the given teamId + * + * @return Board[] + */ + public function findAllAttachedToTeam(string $teamId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('deck_boards') + ->where($qb->expr()->eq('team_id', $qb->createNamedParameter($teamId, IQueryBuilder::PARAM_STR))) + ->orderBy('id'); + return $this->findEntities($qb); + } + /** * Find all boards for a given user */ diff --git a/lib/Listeners/ParticipantCleanupListener.php b/lib/Listeners/ParticipantCleanupListener.php index ebf35eab6b..0d58207d14 100644 --- a/lib/Listeners/ParticipantCleanupListener.php +++ b/lib/Listeners/ParticipantCleanupListener.php @@ -8,43 +8,61 @@ namespace OCA\Deck\Listeners; use OCA\Circles\Events\CircleDestroyedEvent; +use OCA\Circles\Events\RemovingCircleMemberEvent; +use OCA\Circles\Model\Member; use OCA\Deck\Db\Acl; use OCA\Deck\Db\AclMapper; use OCA\Deck\Db\AssignmentMapper; use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Service\TeamBoardService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Group\Events\GroupDeletedEvent; use OCP\User\Events\UserDeletedEvent; -/** @template-implements IEventListener */ +/** @template-implements IEventListener */ class ParticipantCleanupListener implements IEventListener { - private AclMapper $aclMapper; - private AssignmentMapper $assignmentMapper; - private BoardMapper $boardMapper; - - public function __construct(AclMapper $aclMapper, AssignmentMapper $assignmentMapper, BoardMapper $boardMapper) { - $this->aclMapper = $aclMapper; - $this->assignmentMapper = $assignmentMapper; - $this->boardMapper = $boardMapper; + public function __construct( + private AclMapper $aclMapper, + private AssignmentMapper $assignmentMapper, + private BoardMapper $boardMapper, + private TeamBoardService $teamBoardService, + ) { } public function handle(Event $event): void { if ($event instanceof UserDeletedEvent) { - $boards = $this->boardMapper->findAllByOwner($event->getUser()->getUID()); + $userId = $event->getUser()->getUID(); + $transferredBoardIds = $this->teamBoardService->transferTeamBoardsFromDeletedUser($userId); + + $boards = $this->boardMapper->findAllByOwner($userId); foreach ($boards as $board) { + if (in_array($board->getId(), $transferredBoardIds, true)) { + continue; + } $this->boardMapper->delete($board); } - $this->cleanupByParticipant(Acl::PERMISSION_TYPE_USER, $event->getUser()->getUID()); + $this->cleanupByParticipant(Acl::PERMISSION_TYPE_USER, $userId); } if ($event instanceof GroupDeletedEvent) { $this->cleanupByParticipant(Acl::PERMISSION_TYPE_GROUP, $event->getGroup()->getGID()); } + if ($event instanceof RemovingCircleMemberEvent) { + $member = $event->getMember(); + if ($member !== null && $member->getUserType() === Member::TYPE_USER) { + $this->teamBoardService->handleMemberLeftTeam( + $event->getCircle()->getSingleId(), + $member->getUserId() + ); + } + } + if ($event instanceof CircleDestroyedEvent) { $circleId = $event->getCircle()->getSingleId(); + $this->teamBoardService->deleteBoardsAttachedToTeam($circleId); $this->cleanupByParticipant(Acl::PERMISSION_TYPE_CIRCLE, $circleId); } } diff --git a/lib/Service/CirclesService.php b/lib/Service/CirclesService.php index b003fd6550..6c16fd757c 100644 --- a/lib/Service/CirclesService.php +++ b/lib/Service/CirclesService.php @@ -105,4 +105,46 @@ public function getUserCircles(string $userId): array { } return []; } + + /** + * Replace with the next team member to own the board, with higher circles level + */ + public function findNextMemberUserId(string $circleId, ?string $excludeUserId = null): ?string { + if (!$this->circlesEnabled) { + return null; + } + + try { + $circlesManager = Server::get(CirclesManager::class); + $circlesManager->startSuperSession(); + $circle = $circlesManager->getCircle($circleId); + $circleMembers = []; + foreach ($circle->getMembers() as $member) { + if ($member->getUserType() !== Member::TYPE_USER) { + continue; + } + if ($member->getLevel() < Member::LEVEL_MEMBER) { + continue; + } + if ($excludeUserId !== null && $member->getUserId() === $excludeUserId) { + continue; + } + $circleMembers[] = $member; + } + + if ($circleMembers === []) { + return null; + } + + usort( + $circleMembers, + static fn (Member $a, Member $b): int => $b->getLevel() <=> $a->getLevel() + ); + + return $circleMembers[0]->getUserId(); + } catch (Throwable $e) { + } + + return null; + } } diff --git a/lib/Service/TeamBoardService.php b/lib/Service/TeamBoardService.php new file mode 100644 index 0000000000..dc56d32702 --- /dev/null +++ b/lib/Service/TeamBoardService.php @@ -0,0 +1,73 @@ +boardMapper->findAllByOwner($userId) as $board) { + $teamId = $board->getTeamId(); + if ($teamId === null || $teamId === '') { + continue; + } + + $nextOwner = $this->circlesService->findNextMemberUserId($teamId, $userId); + if ($nextOwner === null) { + continue; + } + + $this->boardMapper->transferOwnership($userId, $nextOwner, $board->getId()); + $transferredBoardIds[] = $board->getId(); + } + + return $transferredBoardIds; + } + + /** + * When a user leaves the team,transfer ownership, or delete board + */ + public function handleMemberLeftTeam(string $teamId, string $userId): void { + foreach ($this->boardMapper->findAllAttachedToTeam($teamId) as $board) { + if ($board->getOwner() !== $userId) { + continue; + } + + $nextOwner = $this->circlesService->findNextMemberUserId($teamId, $userId); + if ($nextOwner === null) { + $this->boardMapper->delete($board); + continue; + } + + $this->boardMapper->transferOwnership($userId, $nextOwner, $board->getId()); + } + } + + /** + * When a team is deleted all boards attached to it are deleted too + */ + public function deleteBoardsAttachedToTeam(string $teamId): void { + foreach ($this->boardMapper->findAllAttachedToTeam($teamId) as $board) { + $this->boardMapper->delete($board); + } + } +} From 776b999fe3c11e836ac6e26c1530fcd7457a9c5e Mon Sep 17 00:00:00 2001 From: samin-z Date: Thu, 13 Aug 2026 17:29:33 +0200 Subject: [PATCH 3/3] add unit test Signed-off-by: samin-z --- lib/Db/BoardMapper.php | 2 +- tests/unit/Service/BoardServiceTest.php | 131 ++++++++++++++++- tests/unit/Service/TeamBoardServiceTest.php | 153 ++++++++++++++++++++ 3 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 tests/unit/Service/TeamBoardServiceTest.php diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index adaf2962e1..b334a03910 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -308,7 +308,7 @@ public function findAllByOwner(string $userId, ?int $limit = null, ?int $offset /** * Find all board with the team_id set to the given teamId - * + * * @return Board[] */ public function findAllAttachedToTeam(string $teamId): array { diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index c4e430cb54..6a86a693ac 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -31,7 +31,9 @@ use OC\Federation\CloudIdManager; use OC\L10N\L10N; use OC\Security\SecureRandom; +use OCA\Circles\Model\Circle; use OCA\Deck\Activity\ActivityManager; +use OCA\Deck\BadRequestException; use OCA\Deck\Db\Acl; use OCA\Deck\Db\AclMapper; use OCA\Deck\Db\Assignment; @@ -104,6 +106,9 @@ class BoardServiceTest extends TestCase { /** @var IUserManager */ private $userManager; + /** @var CirclesService|MockObject */ + private $circlesService; + public function setUp(): void { parent::setUp(); $this->l10n = $this->createMock(L10N::class); @@ -125,6 +130,7 @@ public function setUp(): void { $this->boardServiceValidator = $this->createMock(BoardServiceValidator::class); $this->sessionMapper = $this->createMock(SessionMapper::class); $this->userManager = $this->createMock(IUserManager::class); + $this->circlesService = $this->createMock(CirclesService::class); $this->service = new BoardService( $this->boardMapper, @@ -151,7 +157,7 @@ public function setUp(): void { $this->userManager, $this->createMock(SecureRandom::class), $this->createMock(ConfigService::class), - $this->createMock(CirclesService::class), + $this->circlesService, $this->userId ); @@ -227,6 +233,129 @@ public function testCreateDenied() { $b = $this->service->create('MyBoard', 'admin', '00ff00'); } + public function testCreateForTeamEmptyTeamId(): void { + $this->expectException(BadRequestException::class); + $this->service->createForTeam('Team board', $this->userId, '00ff00', ''); + } + + public function testCreateForTeamCirclesDisabled(): void { + $this->circlesService->expects($this->once()) + ->method('isCirclesEnabled') + ->willReturn(false); + $this->expectException(BadRequestException::class); + $this->service->createForTeam('Team board', $this->userId, '00ff00', 'team-a'); + } + + public function testCreateForTeamNotFound(): void { + $this->circlesService->expects($this->once()) + ->method('isCirclesEnabled') + ->willReturn(true); + $this->circlesService->expects($this->once()) + ->method('getCircle') + ->with('team-a') + ->willReturn(null); + $this->expectException(BadRequestException::class); + $this->service->createForTeam('Team board', $this->userId, '00ff00', 'team-a'); + } + + public function testCreateForTeamNotMember(): void { + $this->circlesService->expects($this->once()) + ->method('isCirclesEnabled') + ->willReturn(true); + $this->circlesService->expects($this->once()) + ->method('getCircle') + ->with('team-a') + ->willReturn($this->createMock(Circle::class)); + $this->circlesService->expects($this->once()) + ->method('isUserInCircle') + ->with('team-a', $this->userId) + ->willReturn(false); + $this->expectException(NoPermissionException::class); + $this->service->createForTeam('Team board', $this->userId, '00ff00', 'team-a'); + } + + public function testCreateForTeamSuccess(): void { + $createdBoard = new Board(); + $createdBoard->setId(42); + $createdBoard->setTitle('Team board'); + $createdBoard->setOwner($this->userId); + $createdBoard->setColor('00ff00'); + + $updatedBoard = new Board(); + $updatedBoard->setId(42); + $updatedBoard->setTitle('Team board'); + $updatedBoard->setOwner($this->userId); + $updatedBoard->setColor('00ff00'); + $updatedBoard->setTeamId('team-a'); + + $this->circlesService->expects($this->once()) + ->method('isCirclesEnabled') + ->willReturn(true); + $this->circlesService->expects($this->once()) + ->method('getCircle') + ->with('team-a') + ->willReturn($this->createMock(Circle::class)); + $this->circlesService->expects($this->once()) + ->method('isUserInCircle') + ->with('team-a', $this->userId) + ->willReturn(true); + + /** @var BoardService|MockObject $service */ + $service = $this->getMockBuilder(BoardService::class) + ->setConstructorArgs([ + $this->boardMapper, + $this->stackMapper, + $this->cardMapper, + $this->config, + $this->l10n, + $this->labelMapper, + $this->aclMapper, + $this->permissionService, + $this->assignmentService, + $this->notificationHelper, + $this->assignedUsersMapper, + $this->activityManager, + $this->createMock(CloudFederationProviderManager::class), + $this->createMock(CloudIdManager::class), + $this->createMock(CloudFederationFactory::class), + $this->eventDispatcher, + $this->changeHelper, + $this->urlGenerator, + $this->connection, + $this->boardServiceValidator, + $this->sessionMapper, + $this->userManager, + $this->createMock(SecureRandom::class), + $this->createMock(ConfigService::class), + $this->circlesService, + $this->userId, + ]) + ->onlyMethods(['create', 'addAcl', 'find']) + ->getMock(); + + $service->expects($this->once()) + ->method('create') + ->with('Team board', $this->userId, '00ff00') + ->willReturn($createdBoard); + $this->boardMapper->expects($this->once()) + ->method('update') + ->with($this->callback(function (Board $board) { + return $board->getId() === 42 && $board->getTeamId() === 'team-a'; + })) + ->willReturn($updatedBoard); + $service->expects($this->once()) + ->method('addAcl') + ->with(42, Acl::PERMISSION_TYPE_CIRCLE, 'team-a', true, false, false); + $service->expects($this->once()) + ->method('find') + ->with(42) + ->willReturn($updatedBoard); + + $result = $service->createForTeam('Team board', $this->userId, '00ff00', 'team-a'); + $this->assertSame($updatedBoard, $result); + $this->assertEquals('team-a', $result->getTeamId()); + } + public function testUpdate() { $board = new Board(); $board->setId(123); diff --git a/tests/unit/Service/TeamBoardServiceTest.php b/tests/unit/Service/TeamBoardServiceTest.php new file mode 100644 index 0000000000..00d8c0e861 --- /dev/null +++ b/tests/unit/Service/TeamBoardServiceTest.php @@ -0,0 +1,153 @@ +boardMapper = $this->createMock(BoardMapper::class); + $this->circlesService = $this->createMock(CirclesService::class); + $this->service = new TeamBoardService( + $this->boardMapper, + $this->circlesService, + ); + } + + public function testTransferTeamBoardsFromDeletedUser(): void { + $personalBoard = new Board(); + $personalBoard->setId(1); + $personalBoard->setOwner($this->userId1); + $personalBoard->setTeamId(null); + + $orphanedTeamBoard = new Board(); + $orphanedTeamBoard->setId(2); + $orphanedTeamBoard->setOwner($this->userId1); + $orphanedTeamBoard->setTeamId('team-a'); + + $transferableBoard = new Board(); + $transferableBoard->setId(3); + $transferableBoard->setOwner($this->userId1); + $transferableBoard->setTeamId('team-b'); + + $this->boardMapper->expects($this->once()) + ->method('findAllByOwner') + ->with($this->userId1) + ->willReturn([$personalBoard, $orphanedTeamBoard, $transferableBoard]); + + $this->circlesService->expects($this->exactly(2)) + ->method('findNextMemberUserId') + ->willReturnMap([ + ['team-a', $this->userId1, null], + ['team-b', $this->userId1, $this->userId2], + ]); + + $this->boardMapper->expects($this->once()) + ->method('transferOwnership') + ->with($this->userId1, $this->userId2, 3); + + $this->assertSame([3], $this->service->transferTeamBoardsFromDeletedUser($this->userId1)); + } + + public function testHandleMemberLeftTeamTransfersOwnership(): void { + $board = new Board(); + $board->setId(10); + $board->setOwner($this->userId1); + $board->setTeamId('team-a'); + + $this->boardMapper->expects($this->once()) + ->method('findAllAttachedToTeam') + ->with('team-a') + ->willReturn([$board]); + $this->circlesService->expects($this->once()) + ->method('findNextMemberUserId') + ->with('team-a', $this->userId1) + ->willReturn($this->userId2); + $this->boardMapper->expects($this->once()) + ->method('transferOwnership') + ->with($this->userId1, $this->userId2, 10); + $this->boardMapper->expects($this->never()) + ->method('delete'); + + $this->service->handleMemberLeftTeam('team-a', $this->userId1); + } + + public function testHandleMemberLeftTeamDeletesWhenNoNextMember(): void { + $board = new Board(); + $board->setId(11); + $board->setOwner($this->userId1); + $board->setTeamId('team-a'); + + $this->boardMapper->expects($this->once()) + ->method('findAllAttachedToTeam') + ->with('team-a') + ->willReturn([$board]); + $this->circlesService->expects($this->once()) + ->method('findNextMemberUserId') + ->with('team-a', $this->userId1) + ->willReturn(null); + $this->boardMapper->expects($this->once()) + ->method('delete') + ->with($board); + $this->boardMapper->expects($this->never()) + ->method('transferOwnership'); + + $this->service->handleMemberLeftTeam('team-a', $this->userId1); + } + + public function testHandleMemberLeftTeamSkipsNonOwnerBoards(): void { + $board = new Board(); + $board->setId(12); + $board->setOwner($this->userId2); + $board->setTeamId('team-a'); + + $this->boardMapper->expects($this->once()) + ->method('findAllAttachedToTeam') + ->with('team-a') + ->willReturn([$board]); + $this->circlesService->expects($this->never()) + ->method('findNextMemberUserId'); + $this->boardMapper->expects($this->never()) + ->method('transferOwnership'); + $this->boardMapper->expects($this->never()) + ->method('delete'); + + $this->service->handleMemberLeftTeam('team-a', $this->userId1); + } + + public function testDeleteBoardsAttachedToTeam(): void { + $boardA = new Board(); + $boardA->setId(20); + $boardB = new Board(); + $boardB->setId(21); + + $this->boardMapper->expects($this->once()) + ->method('findAllAttachedToTeam') + ->with('team-a') + ->willReturn([$boardA, $boardB]); + $this->boardMapper->expects($this->exactly(2)) + ->method('delete') + ->withConsecutive([$boardA], [$boardB]); + + $this->service->deleteBoardsAttachedToTeam('team-a'); + } +}