From 5f286ed0f7682d4224487663ff37aba47b265b8a Mon Sep 17 00:00:00 2001 From: Louis Chmn Date: Thu, 3 Sep 2026 20:28:02 +0200 Subject: [PATCH 1/2] feat(TeamSpaceProvider): implement link and quota methods Co-authored-by: Copilot GPT-5.6 Terra Signed-off-by: Fin Clausen Signed-off-by: Louis Chmn --- lib/Folder/FolderManager.php | 28 ++++++++++ lib/TeamSpace/TeamSpaceProvider.php | 25 +++++++++ lib/TeamSpace/TeamSpaceService.php | 65 ++++++++++++++++++++++ tests/Folder/FolderManagerTest.php | 35 ++++++++++++ tests/TeamSpace/TeamSpaceProviderTest.php | 33 +++++++++++ tests/TeamSpace/TeamSpaceServiceTest.php | 68 +++++++++++++++++++++++ 6 files changed, 254 insertions(+) diff --git a/lib/Folder/FolderManager.php b/lib/Folder/FolderManager.php index 52a8a2079..46853da25 100644 --- a/lib/Folder/FolderManager.php +++ b/lib/Folder/FolderManager.php @@ -1288,6 +1288,10 @@ public function setTeamCircleId(int $folderId, string $circleId): void { if ($existingFolderId !== null && $existingFolderId !== $folderId) { throw new Exception('This team already has a team space'); } + $existingCircleId = $this->getTeamCircleId($folderId); + if ($existingCircleId !== null && $existingCircleId !== $circleId) { + throw new Exception('This folder already belongs to another team'); + } $query = $this->connection->getQueryBuilder(); $query->update('group_folders') @@ -1296,6 +1300,30 @@ public function setTeamCircleId(int $folderId, string $circleId): void { $query->executeStatement(); } + /** + * Whether the folder is assigned exclusively to the given circle. A folder + * must have precisely one applicable mapping, and that mapping must be the + * target team; group mappings and additional teams make it ineligible. + * + * @throws Exception + */ + public function isExclusivelyAssignedToCircle(int $folderId, string $circleId): bool { + if ($circleId === '') { + return false; + } + + $query = $this->connection->getQueryBuilder(); + $query->select('group_id', 'circle_id') + ->from('group_folders_groups') + ->where($query->expr()->eq('folder_id', $query->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + /** @var list $rows */ + $rows = $query->executeQuery()->fetchAll(); + + return count($rows) === 1 + && ($rows[0]['group_id'] ?? '') === '' + && ($rows[0]['circle_id'] ?? '') === $circleId; + } + /** * Clear the team ownership of a team space by resetting the * `team_circle_id` column to null. diff --git a/lib/TeamSpace/TeamSpaceProvider.php b/lib/TeamSpace/TeamSpaceProvider.php index 09c3aba22..990c0db73 100644 --- a/lib/TeamSpace/TeamSpaceProvider.php +++ b/lib/TeamSpace/TeamSpaceProvider.php @@ -56,6 +56,31 @@ public function createTeamFolder(Team $team, int $quota = 0): TeamFolder { return $folder; } + /** + * @return list + */ + #[\Override] + public function getLinkableTeamFolders(string $teamId): array { + return $this->service->getLinkableGroupFoldersForCircle($teamId); + } + + #[\Override] + public function linkTeamFolder(string $teamId, int $folderId): TeamFolder { + $this->service->linkExistingTeamSpace($teamId, $folderId); + + $folder = $this->getTeamFolder($teamId); + if ($folder === null) { + throw new \RuntimeException('Linked team space could not be found'); + } + + return $folder; + } + + #[\Override] + public function updateTeamFolderQuota(string $teamId, int $quota): TeamFolder { + return $this->service->updateTeamSpaceQuota($teamId, $quota); + } + #[\Override] public function unlinkTeamFolder(string $teamId): ?TeamFolder { $folder = $this->getTeamFolder($teamId); diff --git a/lib/TeamSpace/TeamSpaceService.php b/lib/TeamSpace/TeamSpaceService.php index 6d5fc2cae..969b780b2 100644 --- a/lib/TeamSpace/TeamSpaceService.php +++ b/lib/TeamSpace/TeamSpaceService.php @@ -236,6 +236,30 @@ public function getTeamSpaceForCircle(string $circleId): ?TeamFolder { return new TeamFolder($folder->id, $folder->mountPoint); } + /** + * Update the storage quota of the team space belonging to the given team. + * + * @param string $circleId The circle single id. + * @param int $quota Quota in bytes; zero means unlimited. + * @return TeamFolder The updated folder. + * @throws \RuntimeException if no team space is linked to the team. + */ + public function updateTeamSpaceQuota(string $circleId, int $quota): TeamFolder { + $folderId = $this->folderManager->getFolderIdByTeamCircleId($circleId); + if ($folderId === null) { + throw new \RuntimeException('No team space linked to this team'); + } + + $this->folderManager->setFolderQuota($folderId, $quota); + + $folder = $this->folderManager->getFolder($folderId); + if ($folder === null) { + throw new \RuntimeException('Team space could not be found after updating quota'); + } + + return new TeamFolder($folder->id, $folder->mountPoint); + } + /** * Return all group folders directly accessible to the given team. * @@ -251,6 +275,47 @@ public function getGroupFoldersForCircle(string $circleId): array { ); } + /** + * Return existing folders directly available to a team that have not been + * made an exclusive team folder for another team. + * + * @return list + */ + public function getLinkableGroupFoldersForCircle(string $circleId): array { + return array_values(array_map( + static fn (FolderDefinition $folder): TeamFolder => new TeamFolder($folder->id, $folder->mountPoint), + array_filter( + $this->folderManager->getFoldersForCircle($circleId), + fn (FolderDefinition $folder): bool => !$folder->isTeamSpace() + && $this->folderManager->isExclusivelyAssignedToCircle($folder->id, $circleId), + ), + )); + } + + /** + * Mark an existing, directly available folder as the team's exclusive + * team folder. The ownership and eligibility checks are repeated at the + * mutation boundary to preserve the one-to-one relationship. + * + * @throws \InvalidArgumentException When the folder is not eligible. + */ + public function linkExistingTeamSpace(string $circleId, int $folderId): void { + if ($this->folderManager->getFolderIdByTeamCircleId($circleId) !== null) { + return; + } + + foreach ($this->folderManager->getFoldersForCircle($circleId) as $folder) { + if ($folder->id === $folderId + && !$folder->isTeamSpace() + && $this->folderManager->isExclusivelyAssignedToCircle($folderId, $circleId)) { + $this->folderManager->setTeamCircleId($folderId, $circleId); + return; + } + } + + throw new \InvalidArgumentException('The folder is not available for this team'); + } + /** * Whether the given circle (looked up by single id) owns a team space. */ diff --git a/tests/Folder/FolderManagerTest.php b/tests/Folder/FolderManagerTest.php index ed16d2424..fdb1a8941 100644 --- a/tests/Folder/FolderManagerTest.php +++ b/tests/Folder/FolderManagerTest.php @@ -773,6 +773,41 @@ public function testSetTeamCircleIdRejectsDuplicateOwner(): void { $this->manager->setTeamCircleId($secondFolderId, 'circle-owner'); } + public function testSetTeamCircleIdRejectsDifferentOwnerForFolder(): void { + $folderId = $this->manager->createFolder('team-space-owned-folder'); + $this->manager->setTeamCircleId($folderId, 'circle-owner'); + + $this->expectException(\Exception::class); + $this->manager->setTeamCircleId($folderId, 'different-circle'); + } + + public function testIsExclusivelyAssignedToCircleRejectsAdditionalMappings(): void { + $folderId = $this->manager->createFolder('exclusive-circle-folder'); + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->insert('group_folders_groups') + ->values([ + 'folder_id' => $query->createNamedParameter($folderId, IQueryBuilder::PARAM_INT), + 'group_id' => $query->createNamedParameter(''), + 'circle_id' => $query->createNamedParameter('circle-owner'), + 'permissions' => $query->createNamedParameter(Constants::PERMISSION_ALL), + ]); + $query->executeStatement(); + + $this->assertTrue($this->manager->isExclusivelyAssignedToCircle($folderId, 'circle-owner')); + + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->insert('group_folders_groups') + ->values([ + 'folder_id' => $query->createNamedParameter($folderId, IQueryBuilder::PARAM_INT), + 'group_id' => $query->createNamedParameter('additional-group'), + 'circle_id' => $query->createNamedParameter(''), + 'permissions' => $query->createNamedParameter(Constants::PERMISSION_ALL), + ]); + $query->executeStatement(); + + $this->assertFalse($this->manager->isExclusivelyAssignedToCircle($folderId, 'circle-owner')); + } + public function testTeamCircleIdIsHydratedAsNullableString(): void { $this->config->expects($this->any()) ->method('getSystemValueInt') diff --git a/tests/TeamSpace/TeamSpaceProviderTest.php b/tests/TeamSpace/TeamSpaceProviderTest.php index f95b1849b..b02da2256 100644 --- a/tests/TeamSpace/TeamSpaceProviderTest.php +++ b/tests/TeamSpace/TeamSpaceProviderTest.php @@ -65,6 +65,39 @@ public function testGetSharedWithReturnsAllFoldersAssignedToTeam(): void { $this->assertSame('https://cloud.example/apps/files/?dir=/Shared%20projects', $resources[1]->getUrl()); } + public function testGetLinkableTeamFoldersDelegatesToService(): void { + $folders = [new TeamFolder(42, 'Engineering')]; + $this->service->expects($this->once()) + ->method('getLinkableGroupFoldersForCircle') + ->with('team-1') + ->willReturn($folders); + + $this->assertSame($folders, $this->provider->getLinkableTeamFolders('team-1')); + } + + public function testLinkTeamFolderLinksAndReturnsFolder(): void { + $folder = new TeamFolder(42, 'Engineering'); + $this->service->expects($this->once()) + ->method('linkExistingTeamSpace') + ->with('team-1', 42); + $this->service->expects($this->once()) + ->method('getTeamSpaceForCircle') + ->with('team-1') + ->willReturn($folder); + + $this->assertSame($folder, $this->provider->linkTeamFolder('team-1', 42)); + } + + public function testUpdateTeamFolderQuotaDelegatesToService(): void { + $folder = new TeamFolder(42, 'Engineering'); + $this->service->expects($this->once()) + ->method('updateTeamSpaceQuota') + ->with('team-1', 1024) + ->willReturn($folder); + + $this->assertSame($folder, $this->provider->updateTeamFolderQuota('team-1', 1024)); + } + public function testIsSharedWithTeamChecksAllFoldersAssignedToTeam(): void { $this->service->expects($this->exactly(2)) ->method('getGroupFoldersForCircle') diff --git a/tests/TeamSpace/TeamSpaceServiceTest.php b/tests/TeamSpace/TeamSpaceServiceTest.php index f2990a2ba..a2b6a99c8 100644 --- a/tests/TeamSpace/TeamSpaceServiceTest.php +++ b/tests/TeamSpace/TeamSpaceServiceTest.php @@ -133,6 +133,25 @@ public function testUnlinkKeepsFolderAndClearsTeamLink(): void { $this->assertSame(42, $this->service->unlinkTeamSpace('team-1')); } + public function testUpdateTeamSpaceQuotaReturnsUpdatedFolder(): void { + $this->folderManager->expects($this->once()) + ->method('getFolderIdByTeamCircleId') + ->with('team-1') + ->willReturn(42); + $this->folderManager->expects($this->once()) + ->method('setFolderQuota') + ->with(42, 1024); + $this->folderManager->expects($this->once()) + ->method('getFolder') + ->with(42) + ->willReturn($this->createTeamSpaceFolder()); + + $folder = $this->service->updateTeamSpaceQuota('team-1', 1024); + + $this->assertSame(42, $folder->getId()); + $this->assertSame('Engineering', $folder->getMountPoint()); + } + public function testGetGroupFoldersForCircleReturnsAllAssignedFolders(): void { $this->folderManager->expects($this->once()) ->method('getFoldersForCircle') @@ -150,6 +169,55 @@ public function testGetGroupFoldersForCircleReturnsAllAssignedFolders(): void { $this->assertSame('Shared projects', $folders[1]->getMountPoint()); } + public function testGetLinkableGroupFoldersRequiresExclusiveTeamAssignment(): void { + $this->folderManager->expects($this->once()) + ->method('getFoldersForCircle') + ->with('team-1') + ->willReturn([ + new FolderDefinition(42, 'Engineering', 0, false, false, 1, 2, []), + new FolderDefinition(43, 'Shared projects', 0, false, false, 3, 4, []), + ]); + $this->folderManager->expects($this->exactly(2)) + ->method('isExclusivelyAssignedToCircle') + ->willReturnCallback(static fn (int $folderId, string $circleId): bool => $folderId === 42 && $circleId === 'team-1'); + + $folders = $this->service->getLinkableGroupFoldersForCircle('team-1'); + + $this->assertCount(1, $folders); + $this->assertSame(42, $folders[0]->getId()); + } + + public function testLinkExistingTeamSpaceRequiresExclusiveTeamAssignment(): void { + $this->folderManager->method('getFolderIdByTeamCircleId')->with('team-1')->willReturn(null); + $this->folderManager->method('getFoldersForCircle')->with('team-1')->willReturn([ + new FolderDefinition(42, 'Engineering', 0, false, false, 1, 2, []), + ]); + $this->folderManager->expects($this->once()) + ->method('isExclusivelyAssignedToCircle') + ->with(42, 'team-1') + ->willReturn(true); + $this->folderManager->expects($this->once()) + ->method('setTeamCircleId') + ->with(42, 'team-1'); + + $this->service->linkExistingTeamSpace('team-1', 42); + } + + public function testLinkExistingTeamSpaceRejectsNonExclusiveAssignment(): void { + $this->folderManager->method('getFolderIdByTeamCircleId')->with('team-1')->willReturn(null); + $this->folderManager->method('getFoldersForCircle')->with('team-1')->willReturn([ + new FolderDefinition(42, 'Engineering', 0, false, false, 1, 2, []), + ]); + $this->folderManager->expects($this->once()) + ->method('isExclusivelyAssignedToCircle') + ->with(42, 'team-1') + ->willReturn(false); + $this->folderManager->expects($this->never())->method('setTeamCircleId'); + + $this->expectException(\InvalidArgumentException::class); + $this->service->linkExistingTeamSpace('team-1', 42); + } + public function testPickBaseNameUsesDisplayName(): void { $this->assertSame('Engineering', $this->service->pickBaseName(new Team('team-1', 'Engineering', null))); } From c36a1ed7eb0d0cb3a84efa1fb8d23fa3a561cd5a Mon Sep 17 00:00:00 2001 From: Louis Chmn Date: Fri, 4 Sep 2026 10:01:12 +0200 Subject: [PATCH 2/2] chore: Bump nextcloud/ocp Signed-off-by: Louis Chmn --- composer.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.lock b/composer.lock index b55c4204b..4450378a6 100644 --- a/composer.lock +++ b/composer.lock @@ -71,12 +71,12 @@ "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "ddc90a88f4ec0166a1c0384cf6d0370e3dc53898" + "reference": "a89b2397daebe5733ec96d9e70a4a2e974a06bfa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/ddc90a88f4ec0166a1c0384cf6d0370e3dc53898", - "reference": "ddc90a88f4ec0166a1c0384cf6d0370e3dc53898", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/a89b2397daebe5733ec96d9e70a4a2e974a06bfa", + "reference": "a89b2397daebe5733ec96d9e70a4a2e974a06bfa", "shasum": "" }, "require": { @@ -95,7 +95,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "35.0.0-dev" + "dev-master": "36.0.0-dev" } }, "notification-url": "https://packagist.org/downloads/", @@ -117,7 +117,7 @@ "issues": "https://github.com/nextcloud-deps/ocp/issues", "source": "https://github.com/nextcloud-deps/ocp/tree/master" }, - "time": "2026-08-26T00:37:28+00:00" + "time": "2026-09-04T01:52:33+00:00" }, { "name": "psr/clock",