From 2c4277cc12fb049aab5b86b7c75198cb20eb8b77 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 17 Oct 2026 09:31:07 +0200 Subject: [PATCH 1/4] feat(organisations): report measured usage, null for what cannot be measured The organisation usage block reported storage, bandwidth and requests as a fixed 0. GET /api/organisations/{uuid} now measures storage (the file cache size of the folders of the organisation's live objects) and requests (this hour's quota-middleware counter, only when one is kept); users and groups stay member counts. Anything not measured, including bandwidth and every figure in lists, is null instead of 0. Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Controller/OrganisationController.php | 20 ++- lib/Db/FileMapper.php | 29 ++++ lib/Db/MagicMapper.php | 45 ++++++ lib/Db/Organisation.php | 18 +-- lib/Service/OrganisationUsageService.php | 153 ++++++++++++++++++ .../organisation-usage-measured/proposal.md | 29 ++++ .../specs/organisation-usage-measured/spec.md | 32 ++++ .../organisation-usage-measured/tasks.md | 7 + tests/Service/ControllersIntegrationTest2.php | 3 +- .../Controller/OrganisationControllerTest.php | 41 ++++- .../OrganisationGrantManageGuardTest.php | 3 +- tests/Unit/Db/OrganisationTest.php | 6 +- .../ActiveOrganisationManagementTest.php | 3 +- .../Service/EdgeCasesErrorHandlingTest.php | 3 +- tests/Unit/Service/OrganisationCrudTest.php | 3 +- .../Service/OrganisationUsageServiceTest.php | 150 +++++++++++++++++ .../UserOrganisationRelationshipTest.php | 3 +- 17 files changed, 522 insertions(+), 26 deletions(-) create mode 100644 lib/Service/OrganisationUsageService.php create mode 100644 openspec/changes/organisation-usage-measured/proposal.md create mode 100644 openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md create mode 100644 openspec/changes/organisation-usage-measured/tasks.md create mode 100644 tests/Unit/Service/OrganisationUsageServiceTest.php diff --git a/lib/Controller/OrganisationController.php b/lib/Controller/OrganisationController.php index 2d70263c3b..370eb4c94d 100644 --- a/lib/Controller/OrganisationController.php +++ b/lib/Controller/OrganisationController.php @@ -35,6 +35,7 @@ use OCA\OpenRegister\Db\OrganisationMapper; use OCA\OpenRegister\Db\TenantUsageMapper; use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\OrganisationUsageService; use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights; use OCA\OpenRegister\Service\TenantLifecycleService; use OCP\AppFramework\Controller; @@ -127,6 +128,7 @@ class OrganisationController extends Controller { * @param TenantUsageMapper $tenantUsageMapper Usage mapper * @param IUserSession $userSession User session for authorization checks * @param IGroupManager $groupManager Group manager for admin checks + * @param OrganisationUsageService $usageService Measures an organisation's usage * * @spec openspec/specs/tenant-lifecycle/spec.md */ @@ -140,6 +142,7 @@ public function __construct( TenantUsageMapper $tenantUsageMapper, IUserSession $userSession, IGroupManager $groupManager, + private readonly OrganisationUsageService $usageService, ) { parent::__construct(appName: $appName, request: $request); $this->organisationService = $organisationService; @@ -262,7 +265,7 @@ public function index(): JSONResponse { * active: bool|null, parent: null|string, children: array, * quota: array{storage: int|null, bandwidth: int|null, * requests: int|null, users: null, groups: null}, - * usage: array{storage: 0, bandwidth: 0, requests: 0, + * usage: array{storage: null, bandwidth: null, requests: null, * users: int<0, max>, groups: int<0, max>}, authorization: array, * created: null|string, updated: null|string}|null}, array> * @@ -332,7 +335,7 @@ public function setActive(string $uuid): JSONResponse { * active: bool|null, parent: null|string, children: array, * quota: array{storage: int|null, bandwidth: int|null, * requests: int|null, users: null, groups: null}, - * usage: array{storage: 0, bandwidth: 0, requests: 0, + * usage: array{storage: null, bandwidth: null, requests: null, * users: int<0, max>, groups: int<0, max>}, authorization: array, * created: null|string, updated: null|string}|null}, array> * @@ -394,7 +397,7 @@ public function getActive(): JSONResponse { * parent: null|string, children: array, * quota: array{storage: int|null, bandwidth: int|null, * requests: int|null, users: null, groups: null}, - * usage: array{storage: 0, bandwidth: 0, requests: 0, + * usage: array{storage: null, bandwidth: null, requests: null, * users: int<0, max>, groups: int<0, max>}, authorization: array, * created: null|string, updated: null|string}}, array> * @@ -635,7 +638,7 @@ public function leave(string $uuid): JSONResponse { * parent: null|string, children: array, * quota: array{storage: int|null, bandwidth: int|null, * requests: int|null, users: null, groups: null}, - * usage: array{storage: 0, bandwidth: 0, requests: 0, + * usage: array{storage: int|null, bandwidth: null, requests: int|null, * users: int<0, max>, groups: int<0, max>}, authorization: array, * created: null|string, updated: null|string}}, array> * @@ -659,9 +662,14 @@ public function show(string $uuid): JSONResponse { $children = $this->organisationMapper->findChildrenChain($uuid); $organisation->setChildren($children); + // The measured usage replaces the entity's, which cannot measure + // storage or requests. + $data = $organisation->jsonSerialize(); + $data['usage'] = $this->usageService->measure(organisation: $organisation); + return new JSONResponse( data: [ - 'organisation' => $organisation->jsonSerialize(), + 'organisation' => $data, ], statusCode: Http::STATUS_OK ); @@ -786,7 +794,7 @@ public function patch(string $uuid): JSONResponse { * children: array, * quota: array{storage: int|null, bandwidth: int|null, * requests: int|null, users: null, groups: null}, - * usage: array{storage: 0, bandwidth: 0, requests: 0, + * usage: array{storage: null, bandwidth: null, requests: null, * users: int<0, max>, groups: int<0, max>}, authorization: array, * created: null|string, updated: null|string}>, * limit?: int<1, 100>, offset?: int<0, max>, count?: int<0, max>}, diff --git a/lib/Db/FileMapper.php b/lib/Db/FileMapper.php index 826b7d9808..81b3923aea 100644 --- a/lib/Db/FileMapper.php +++ b/lib/Db/FileMapper.php @@ -1076,6 +1076,35 @@ public function getTotalFilesSize(): int { return (int)($row['total_size'] ?? 0); }//end getTotalFilesSize() + /** + * The summed size of the given files or folders. + * + * A folder's size in the file cache is the size of everything under it. A + * negative size means the entry was never scanned and is left out. + * + * @param list $fileIds The file cache ids. + * + * @return int The total size in bytes, 0 for no ids. + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md + */ + public function sumSizesByFileIds(array $fileIds): int { + $total = 0; + foreach (array_chunk(array_values(array_unique($fileIds)), 1000) as $chunk) { + $qb = $this->db->getQueryBuilder(); + $qb->selectAlias($qb->createFunction('SUM(fc.size)'), 'total_size') + ->from('filecache', 'fc') + ->where($qb->expr()->in('fc.fileid', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb->expr()->gte('fc.size', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + $total += (int)($row['total_size'] ?? 0); + } + + return $total; + }//end sumSizesByFileIds() + /** * Find files in Nextcloud that are not tracked in the extraction system yet * diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php index 2e802ec60a..fa1c7c0105 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -7594,6 +7594,51 @@ static function (array $first, array $second): int { return $found; }//end findDeletedAcrossAllMagicTables() + /** + * The file ids of the object folders that belong to one organisation. + * + * Scans every magic table for live rows whose `_organisation` is the given + * uuid and returns their distinct numeric `_folder` values. Not narrowed to + * a caller: it measures the organisation, and the caller decides who may + * see the measurement. + * + * @param string $organisationUuid The organisation uuid. + * + * @return list The folder file ids. + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md + */ + public function findFolderIdsByOrganisation(string $organisationUuid): array { + $organisationCol = 't.' . self::METADATA_PREFIX . 'organisation'; + $folderCol = 't.' . self::METADATA_PREFIX . 'folder'; + $deletedCol = 't.' . self::METADATA_PREFIX . 'deleted'; + $folderIds = []; + + foreach (array_keys($this->discoverMagicTables()) as $fullTableName) { + $bareTableName = substr($fullTableName, strlen($this->getTablePrefix())); + try { + $qb = $this->db->getQueryBuilder(); + $qb->selectDistinct($folderCol) + ->from($bareTableName, 't') + ->where($qb->expr()->eq($organisationCol, $qb->createNamedParameter($organisationUuid))) + ->andWhere($qb->expr()->isNotNull($folderCol)) + ->andWhere($qb->expr()->isNull($deletedCol)); + $result = $qb->executeQuery(); + while (($folder = $result->fetchOne()) !== false) { + if (is_numeric($folder) === true) { + $folderIds[(int)$folder] = true; + } + } + + $result->closeCursor(); + } catch (\Exception $e) { + continue; + } + }//end foreach + + return array_keys($folderIds); + }//end findFolderIdsByOrganisation() + /** * Count all soft-deleted objects across ALL magic tables. * diff --git a/lib/Db/Organisation.php b/lib/Db/Organisation.php index 0972bafa88..456aab8a4f 100644 --- a/lib/Db/Organisation.php +++ b/lib/Db/Organisation.php @@ -1017,9 +1017,9 @@ public function setChildren(?array $children): static { * groups: null * }, * usage: array{ - * storage: 0, - * bandwidth: 0, - * requests: 0, + * storage: null, + * bandwidth: null, + * requests: null, * users: int<0, max>, * groups: int<0, max> * }, @@ -1072,13 +1072,13 @@ public function jsonSerialize(): array { 'groups' => null, // To be set via admin configuration. ], + // Storage, bandwidth and requests are measured by + // OrganisationUsageService; the entity cannot measure them, so they + // are null here rather than a 0 that reads as "nothing used". 'usage' => [ - 'storage' => 0, - // To be calculated from actual usage. - 'bandwidth' => 0, - // To be calculated from actual usage. - 'requests' => 0, - // To be calculated from actual usage. + 'storage' => null, + 'bandwidth' => null, + 'requests' => null, 'users' => count($users), 'groups' => count($groups), ], diff --git a/lib/Service/OrganisationUsageService.php b/lib/Service/OrganisationUsageService.php new file mode 100644 index 0000000000..f429fc372b --- /dev/null +++ b/lib/Service/OrganisationUsageService.php @@ -0,0 +1,153 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service; + +use DateTime; +use Exception; +use OCA\OpenRegister\Db\FileMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\Organisation; +use Psr\Log\LoggerInterface; + +/** + * Measures an organisation's usage. + * + * A figure that cannot be measured is null, never 0: a 0 reads as "nothing + * used", which is a claim this service only makes when it counted. + */ +class OrganisationUsageService { + /** + * Constructor. + * + * @param MagicMapper $magicMapper Reads the folders of the organisation's objects. + * @param FileMapper $fileMapper Sums file cache sizes. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly MagicMapper $magicMapper, + private readonly FileMapper $fileMapper, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Measure the organisation's usage. + * + * - `users`, `groups`: the organisation's members and groups. + * - `storage`: bytes of the files in the folders of the organisation's live + * objects; null when it could not be read. + * - `requests`: the requests counted for it this hour by the quota + * middleware, which counts only for an organisation with a request quota + * and only with APCu; null otherwise. + * - `bandwidth`: null. The middleware counts JSON response bodies only, + * which is not the organisation's bandwidth. + * + * @param Organisation $organisation The organisation. + * + * @return array{storage: int|null, bandwidth: null, requests: int|null, users: int, groups: int} + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md + */ + public function measure(Organisation $organisation): array { + return [ + 'storage' => $this->measureStorage(organisation: $organisation), + 'bandwidth' => null, + 'requests' => $this->measureRequests(organisation: $organisation), + 'users' => count($organisation->getUserIds()), + 'groups' => count($organisation->getGroups()), + ]; + }//end measure() + + /** + * The bytes stored in the folders of the organisation's live objects. + * + * @param Organisation $organisation The organisation. + * + * @return int|null The bytes, or null when they could not be read. + */ + private function measureStorage(Organisation $organisation): ?int { + $uuid = $organisation->getUuid(); + if ($uuid === null || $uuid === '') { + return null; + } + + try { + $folderIds = $this->magicMapper->findFolderIdsByOrganisation(organisationUuid: $uuid); + return $this->fileMapper->sumSizesByFileIds(fileIds: $folderIds); + } catch (Exception $e) { + $this->logger->warning( + message: '[OrganisationUsageService] Could not measure storage', + context: ['uuid' => $uuid, 'error' => $e->getMessage()] + ); + return null; + } + }//end measureStorage() + + /** + * The requests counted for the organisation this hour. + * + * @param Organisation $organisation The organisation. + * + * @return int|null The count, or null when nothing counts them. + */ + private function measureRequests(Organisation $organisation): ?int { + $uuid = $organisation->getUuid(); + if ($uuid === null || $uuid === '' || $organisation->getRequestQuota() === null + || $this->apcuAvailable() === false + ) { + return null; + } + + $hourBucket = (new DateTime())->format('YmdH'); + return ($this->apcuFetchInt(key: "or_quota_{$uuid}_{$hourBucket}") ?? 0); + }//end measureRequests() + + /** + * Whether APCu is enabled. + * + * @return bool True when APCu can be read. + */ + protected function apcuAvailable(): bool { + return function_exists('apcu_enabled') === true && apcu_enabled() === true; + }//end apcuAvailable() + + /** + * Read an integer counter from APCu. + * + * @param string $key The key. + * + * @return int|null The value, or null when the key is not set. + */ + protected function apcuFetchInt(string $key): ?int { + $success = false; + $value = apcu_fetch($key, $success); + if ($success !== true) { + return null; + } + + return (int)$value; + }//end apcuFetchInt() +}//end class diff --git a/openspec/changes/organisation-usage-measured/proposal.md b/openspec/changes/organisation-usage-measured/proposal.md new file mode 100644 index 0000000000..3b07d83832 --- /dev/null +++ b/openspec/changes/organisation-usage-measured/proposal.md @@ -0,0 +1,29 @@ +# Proposal: organisation-usage-measured + +## Why + +An organisation's `usage` block reported `storage: 0`, `bandwidth: 0` and +`requests: 0` for every organisation, always. A 0 reads as "nothing used", so +a quota card drawn from it told every tenant they used nothing. Only `users` +and `groups` were real. + +## What Changes + +- `GET /api/organisations/{uuid}` measures usage through a new + `OrganisationUsageService`: + - `users`, `groups`: the organisation's members and groups (unchanged). + - `storage`: the summed file cache size of the folders of the + organisation's live objects across all magic tables; null when it cannot + be read. + - `requests`: this hour's counter of the quota middleware. The middleware + only counts for an organisation with a request quota, and only with APCu, + so it is null otherwise. + - `bandwidth`: null. The middleware counts JSON response bodies only, which + is not bandwidth. +- Everywhere else the entity is serialised (lists, search, active), + `storage`, `bandwidth` and `requests` are null instead of 0: the entity + cannot measure them. +- `MagicMapper::findFolderIdsByOrganisation()` and + `FileMapper::sumSizesByFileIds()`. + +Measuring runs only after the existing access check in `show`. diff --git a/openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md b/openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md new file mode 100644 index 0000000000..c8c76d68c2 --- /dev/null +++ b/openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Organisation usage reports only what is measured + +An organisation's `usage` block SHALL report a figure only when OpenRegister +measured it, and SHALL report null, never 0, for a figure it cannot measure. + +#### Scenario: One organisation is measured + +- **GIVEN** a viewer with access to an organisation +- **WHEN** they read `GET /api/organisations/{uuid}` +- **THEN** `usage.users` and `usage.groups` count the organisation's members and groups +- **AND** `usage.storage` is the bytes in the folders of the organisation's live objects +- **AND** `usage.requests` is this hour's request counter when the organisation has a request quota and APCu is enabled, else null +- **AND** `usage.bandwidth` is null + +#### Scenario: A storage read fails + +- **GIVEN** the object tables cannot be read +- **WHEN** usage is measured +- **THEN** `usage.storage` is null + +#### Scenario: Lists do not claim usage + +- **WHEN** an organisation is serialised outside the single read +- **THEN** `usage.storage`, `usage.bandwidth` and `usage.requests` are null + +#### Scenario: A refused organisation is not measured + +- **GIVEN** a viewer without access to the organisation +- **WHEN** they read it +- **THEN** the response is 403 and nothing is measured diff --git a/openspec/changes/organisation-usage-measured/tasks.md b/openspec/changes/organisation-usage-measured/tasks.md new file mode 100644 index 0000000000..4fb8f77154 --- /dev/null +++ b/openspec/changes/organisation-usage-measured/tasks.md @@ -0,0 +1,7 @@ +# Tasks: organisation-usage-measured + +- [x] 1. `OrganisationUsageService::measure()` with users, groups, storage, requests; bandwidth null. +- [x] 2. `MagicMapper::findFolderIdsByOrganisation()` and `FileMapper::sumSizesByFileIds()`. +- [x] 3. `OrganisationController::show()` returns the measured usage after the access check. +- [x] 4. `Organisation::jsonSerialize()` reports null for what it cannot measure. +- [x] 5. Unit tests. diff --git a/tests/Service/ControllersIntegrationTest2.php b/tests/Service/ControllersIntegrationTest2.php index 8d73834c68..a136ae672e 100644 --- a/tests/Service/ControllersIntegrationTest2.php +++ b/tests/Service/ControllersIntegrationTest2.php @@ -1861,7 +1861,8 @@ private function buildOrganisationController(): OrganisationController { \OC::$server->get(TenantLifecycleService::class), \OC::$server->get(TenantUsageMapper::class), \OC::$server->get(IUserSession::class), - \OC::$server->get(IGroupManager::class) + \OC::$server->get(IGroupManager::class), + \OC::$server->get(\OCA\OpenRegister\Service\OrganisationUsageService::class) ); }//end buildOrganisationController() diff --git a/tests/Unit/Controller/OrganisationControllerTest.php b/tests/Unit/Controller/OrganisationControllerTest.php index 0ef7d1afb2..b616dbd34b 100644 --- a/tests/Unit/Controller/OrganisationControllerTest.php +++ b/tests/Unit/Controller/OrganisationControllerTest.php @@ -10,6 +10,7 @@ use OCA\OpenRegister\Db\OrganisationMapper; use OCA\OpenRegister\Db\TenantUsageMapper; use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\OrganisationUsageService; use OCA\OpenRegister\Service\TenantLifecycleService; use OCP\AppFramework\Http; use OCP\EventDispatcher\IEventDispatcher; @@ -39,6 +40,7 @@ class OrganisationControllerTest extends TestCase { private LoggerInterface&MockObject $logger; private IUserSession&MockObject $userSession; private IGroupManager&MockObject $groupManager; + private OrganisationUsageService&MockObject $usageService; protected function setUp(): void { parent::setUp(); @@ -49,6 +51,7 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->userSession = $this->createMock(IUserSession::class); $this->groupManager = $this->createMock(IGroupManager::class); + $this->usageService = $this->createMock(OrganisationUsageService::class); $this->controller = new OrganisationController( 'openregister', @@ -59,7 +62,8 @@ protected function setUp(): void { $this->createMock(TenantLifecycleService::class), $this->createMock(TenantUsageMapper::class), $this->userSession, - $this->groupManager + $this->groupManager, + $this->usageService ); } @@ -481,6 +485,38 @@ public function testLeaveExceptionWithUserId(): void { // show() // ────────────────────────────────────────────── + /** + * Show replaces the entity's unmeasured usage with the measured one. + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md + */ + public function testShowReturnsMeasuredUsage(): void { + $org = $this->createMock(Organisation::class); + $org->method('jsonSerialize')->willReturn(['uuid' => 'uuid-1', 'usage' => ['storage' => null, 'requests' => null, 'users' => 2]]); + $measured = ['storage' => 4096, 'bandwidth' => null, 'requests' => null, 'users' => 2, 'groups' => 0]; + $this->usageService->expects($this->once())->method('measure')->with($org)->willReturn($measured); + + $this->organisationService->method('hasAccessToOrganisation')->willReturn(true); + $this->organisationMapper->method('findByUuid')->willReturn($org); + $this->organisationMapper->method('findChildrenChain')->willReturn([]); + + $data = $this->controller->show('uuid-1')->getData(); + + $this->assertSame($measured, $data['organisation']['usage']); + } + + /** + * A refused organisation is not measured. + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md + */ + public function testShowDoesNotMeasureWhenNoAccess(): void { + $this->organisationService->method('hasAccessToOrganisation')->willReturn(false); + $this->usageService->expects($this->never())->method('measure'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller->show('uuid-1')->getStatus()); + } + public function testShowReturnsForbiddenWhenNoAccess(): void { $this->organisationService->method('hasAccessToOrganisation')->willReturn(false); @@ -2183,7 +2219,8 @@ private function controllerWithRealLifecycle(): OrganisationController { $lifecycle, $this->createMock(TenantUsageMapper::class), $this->userSession, - $this->groupManager + $this->groupManager, + $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) ); } diff --git a/tests/Unit/Controller/OrganisationGrantManageGuardTest.php b/tests/Unit/Controller/OrganisationGrantManageGuardTest.php index 093edbc62a..c6a6562cd1 100644 --- a/tests/Unit/Controller/OrganisationGrantManageGuardTest.php +++ b/tests/Unit/Controller/OrganisationGrantManageGuardTest.php @@ -77,7 +77,8 @@ protected function setUp(): void { $this->createMock(TenantLifecycleService::class), $this->createMock(TenantUsageMapper::class), $this->createMock(IUserSession::class), - $this->createMock(IGroupManager::class) + $this->createMock(IGroupManager::class), + $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) ); }//end setUp() diff --git a/tests/Unit/Db/OrganisationTest.php b/tests/Unit/Db/OrganisationTest.php index ee987cfddc..5793ac178f 100644 --- a/tests/Unit/Db/OrganisationTest.php +++ b/tests/Unit/Db/OrganisationTest.php @@ -553,9 +553,9 @@ public function testJsonSerializeUsageStructure(): void { $json = $this->organisation->jsonSerialize(); $usage = $json['usage']; - $this->assertSame(0, $usage['storage']); - $this->assertSame(0, $usage['bandwidth']); - $this->assertSame(0, $usage['requests']); + $this->assertNull($usage['storage']); + $this->assertNull($usage['bandwidth']); + $this->assertNull($usage['requests']); $this->assertSame(2, $usage['users']); $this->assertSame(3, $usage['groups']); } diff --git a/tests/Unit/Service/ActiveOrganisationManagementTest.php b/tests/Unit/Service/ActiveOrganisationManagementTest.php index 7311f5fc34..6ae5bad039 100644 --- a/tests/Unit/Service/ActiveOrganisationManagementTest.php +++ b/tests/Unit/Service/ActiveOrganisationManagementTest.php @@ -180,7 +180,8 @@ protected function setUp(): void { tenantLifecycleService: $this->createMock(TenantLifecycleService::class), tenantUsageMapper: $this->createMock(TenantUsageMapper::class), userSession: $this->userSession, - groupManager: $this->groupManager + groupManager: $this->groupManager, + usageService: $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) ); } diff --git a/tests/Unit/Service/EdgeCasesErrorHandlingTest.php b/tests/Unit/Service/EdgeCasesErrorHandlingTest.php index 6f74cead0e..b57d3a9153 100644 --- a/tests/Unit/Service/EdgeCasesErrorHandlingTest.php +++ b/tests/Unit/Service/EdgeCasesErrorHandlingTest.php @@ -102,7 +102,8 @@ protected function setUp(): void { $this->createMock(TenantLifecycleService::class), $this->createMock(TenantUsageMapper::class), $this->userSession, - $this->groupManager + $this->groupManager, + $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) ); } diff --git a/tests/Unit/Service/OrganisationCrudTest.php b/tests/Unit/Service/OrganisationCrudTest.php index 58b40c78f3..c557cb2cfe 100644 --- a/tests/Unit/Service/OrganisationCrudTest.php +++ b/tests/Unit/Service/OrganisationCrudTest.php @@ -181,7 +181,8 @@ protected function setUp(): void { tenantLifecycleService: $this->createMock(TenantLifecycleService::class), tenantUsageMapper: $this->createMock(TenantUsageMapper::class), userSession: $this->userSession, - groupManager: $this->groupManager + groupManager: $this->groupManager, + usageService: $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) ); } diff --git a/tests/Unit/Service/OrganisationUsageServiceTest.php b/tests/Unit/Service/OrganisationUsageServiceTest.php new file mode 100644 index 0000000000..21f5b775f9 --- /dev/null +++ b/tests/Unit/Service/OrganisationUsageServiceTest.php @@ -0,0 +1,150 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service; + +use Exception; +use OCA\OpenRegister\Db\FileMapper; +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Service\OrganisationUsageService; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * What the usage block claims, and what it refuses to claim: a figure the + * service cannot measure is null, never 0. + */ +class OrganisationUsageServiceTest extends TestCase { + private MagicMapper&MockObject $magicMapper; + private FileMapper&MockObject $fileMapper; + + protected function setUp(): void { + parent::setUp(); + $this->magicMapper = $this->createMock(MagicMapper::class); + $this->fileMapper = $this->createMock(FileMapper::class); + } + + /** + * A service whose APCu is replaced by the given counters. + * + * @param bool $available Whether APCu is enabled. + * @param array $counters The counters by key. + * + * @return OrganisationUsageService The service. + */ + private function service(bool $available, array $counters = []): OrganisationUsageService { + return new class($this->magicMapper, $this->fileMapper, new NullLogger(), $available, $counters) extends OrganisationUsageService { + /** + * @param MagicMapper $magicMapper The magic mapper. + * @param FileMapper $fileMapper The file mapper. + * @param NullLogger $logger The logger. + * @param bool $available Whether APCu is enabled. + * @param array $counters The counters by key. + */ + public function __construct( + MagicMapper $magicMapper, + FileMapper $fileMapper, + NullLogger $logger, + private bool $available, + private array $counters, + ) { + parent::__construct($magicMapper, $fileMapper, $logger); + } + + protected function apcuAvailable(): bool { + return $this->available; + } + + protected function apcuFetchInt(string $key): ?int { + return $this->counters[$key] ?? null; + } + }; + } + + /** + * An organisation with two members, one group and the given request quota. + * + * @param int|null $requestQuota The request quota. + * + * @return Organisation The organisation. + */ + private function organisation(?int $requestQuota = null): Organisation { + $organisation = new Organisation(); + $organisation->setUuid('org-1'); + $organisation->addUser('alice'); + $organisation->addUser('bob'); + $organisation->setGroups(['staff']); + $organisation->setRequestQuota($requestQuota); + return $organisation; + } + + public function testUsersAndGroupsCountTheMembers(): void { + $usage = $this->service(available: false)->measure($this->organisation()); + + $this->assertSame(2, $usage['users']); + $this->assertSame(1, $usage['groups']); + } + + public function testStorageSumsTheFoldersOfTheOrganisationsObjects(): void { + $this->magicMapper->expects($this->once())->method('findFolderIdsByOrganisation') + ->with('org-1')->willReturn([11, 12]); + $this->fileMapper->expects($this->once())->method('sumSizesByFileIds') + ->with([11, 12])->willReturn(5120); + + $this->assertSame(5120, $this->service(available: false)->measure($this->organisation())['storage']); + } + + public function testStorageThatCannotBeReadIsNullNotZero(): void { + $this->magicMapper->method('findFolderIdsByOrganisation')->willThrowException(new Exception('db down')); + + $this->assertNull($this->service(available: false)->measure($this->organisation())['storage']); + } + + public function testRequestsAreNullWithoutAQuotaBecauseNothingCountsThem(): void { + $usage = $this->service(available: true, counters: ['or_quota_org-1_' . date('YmdH') => 7])->measure($this->organisation()); + + $this->assertNull($usage['requests']); + } + + public function testRequestsAreNullWithoutApcu(): void { + $this->assertNull($this->service(available: false)->measure($this->organisation(requestQuota: 100))['requests']); + } + + public function testRequestsReadThisHoursCounter(): void { + $usage = $this->service(available: true, counters: ['or_quota_org-1_' . date('YmdH') => 7]) + ->measure($this->organisation(requestQuota: 100)); + + $this->assertSame(7, $usage['requests']); + } + + public function testRequestsAreZeroWhenCountedAndNoneThisHour(): void { + $usage = $this->service(available: true)->measure($this->organisation(requestQuota: 100)); + + $this->assertSame(0, $usage['requests']); + } + + public function testBandwidthIsNeverClaimed(): void { + $this->assertNull($this->service(available: true)->measure($this->organisation(requestQuota: 100))['bandwidth']); + } +} diff --git a/tests/Unit/Service/UserOrganisationRelationshipTest.php b/tests/Unit/Service/UserOrganisationRelationshipTest.php index 4688ef8ecd..6cbcd6ad09 100644 --- a/tests/Unit/Service/UserOrganisationRelationshipTest.php +++ b/tests/Unit/Service/UserOrganisationRelationshipTest.php @@ -180,7 +180,8 @@ protected function setUp(): void { tenantLifecycleService: $this->createMock(TenantLifecycleService::class), tenantUsageMapper: $this->createMock(TenantUsageMapper::class), userSession: $this->userSession, - groupManager: $this->groupManager + groupManager: $this->groupManager, + usageService: $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) ); } From 8b5244a2f74226b47221927c9937432c4f9cf2e2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 17 Oct 2026 09:32:38 +0200 Subject: [PATCH 2/4] test(organisations): name the arguments and document the usage tests Assisted-by: ClaudeCode:claude-opus-5-5 --- .../Service/OrganisationUsageServiceTest.php | 94 ++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/tests/Unit/Service/OrganisationUsageServiceTest.php b/tests/Unit/Service/OrganisationUsageServiceTest.php index 21f5b775f9..5d746c434d 100644 --- a/tests/Unit/Service/OrganisationUsageServiceTest.php +++ b/tests/Unit/Service/OrganisationUsageServiceTest.php @@ -36,13 +36,29 @@ * service cannot measure is null, never 0. */ class OrganisationUsageServiceTest extends TestCase { + /** + * The magic mapper double. + * + * @var MagicMapper&MockObject + */ private MagicMapper&MockObject $magicMapper; + + /** + * The file mapper double. + * + * @var FileMapper&MockObject + */ private FileMapper&MockObject $fileMapper; + /** + * Create the mapper doubles. + * + * @return void + */ protected function setUp(): void { parent::setUp(); - $this->magicMapper = $this->createMock(MagicMapper::class); - $this->fileMapper = $this->createMock(FileMapper::class); + $this->magicMapper = $this->createMock(originalClassName: MagicMapper::class); + $this->fileMapper = $this->createMock(originalClassName: FileMapper::class); } /** @@ -69,13 +85,25 @@ public function __construct( private bool $available, private array $counters, ) { - parent::__construct($magicMapper, $fileMapper, $logger); + parent::__construct(magicMapper: $magicMapper, fileMapper: $fileMapper, logger: $logger); } + /** + * Whether the stand-in APCu is enabled. + * + * @return bool + */ protected function apcuAvailable(): bool { return $this->available; } + /** + * Read a stand-in counter. + * + * @param string $key The key. + * + * @return int|null + */ protected function apcuFetchInt(string $key): ?int { return $this->counters[$key] ?? null; } @@ -99,52 +127,92 @@ private function organisation(?int $requestQuota = null): Organisation { return $organisation; } + /** + * Users and groups count the members. + * + * @return void + */ public function testUsersAndGroupsCountTheMembers(): void { $usage = $this->service(available: false)->measure($this->organisation()); - $this->assertSame(2, $usage['users']); - $this->assertSame(1, $usage['groups']); + $this->assertSame(expected: 2, actual: $usage['users']); + $this->assertSame(expected: 1, actual: $usage['groups']); } + /** + * Storage sums the folders of the organisations objects. + * + * @return void + */ public function testStorageSumsTheFoldersOfTheOrganisationsObjects(): void { $this->magicMapper->expects($this->once())->method('findFolderIdsByOrganisation') ->with('org-1')->willReturn([11, 12]); $this->fileMapper->expects($this->once())->method('sumSizesByFileIds') ->with([11, 12])->willReturn(5120); - $this->assertSame(5120, $this->service(available: false)->measure($this->organisation())['storage']); + $this->assertSame(expected: 5120, actual: $this->service(available: false)->measure($this->organisation())['storage']); } + /** + * Storage that cannot be read is null not zero. + * + * @return void + */ public function testStorageThatCannotBeReadIsNullNotZero(): void { - $this->magicMapper->method('findFolderIdsByOrganisation')->willThrowException(new Exception('db down')); + $this->magicMapper->method('findFolderIdsByOrganisation')->willThrowException(exception: new Exception(message: 'db down')); - $this->assertNull($this->service(available: false)->measure($this->organisation())['storage']); + $this->assertNull(actual: $this->service(available: false)->measure($this->organisation())['storage']); } + /** + * Requests are null without a quota because nothing counts them. + * + * @return void + */ public function testRequestsAreNullWithoutAQuotaBecauseNothingCountsThem(): void { $usage = $this->service(available: true, counters: ['or_quota_org-1_' . date('YmdH') => 7])->measure($this->organisation()); - $this->assertNull($usage['requests']); + $this->assertNull(actual: $usage['requests']); } + /** + * Requests are null without apcu. + * + * @return void + */ public function testRequestsAreNullWithoutApcu(): void { - $this->assertNull($this->service(available: false)->measure($this->organisation(requestQuota: 100))['requests']); + $this->assertNull(actual: $this->service(available: false)->measure($this->organisation(requestQuota: 100))['requests']); } + /** + * Requests read this hours counter. + * + * @return void + */ public function testRequestsReadThisHoursCounter(): void { $usage = $this->service(available: true, counters: ['or_quota_org-1_' . date('YmdH') => 7]) ->measure($this->organisation(requestQuota: 100)); - $this->assertSame(7, $usage['requests']); + $this->assertSame(expected: 7, actual: $usage['requests']); } + /** + * Requests are zero when counted and none this hour. + * + * @return void + */ public function testRequestsAreZeroWhenCountedAndNoneThisHour(): void { $usage = $this->service(available: true)->measure($this->organisation(requestQuota: 100)); - $this->assertSame(0, $usage['requests']); + $this->assertSame(expected: 0, actual: $usage['requests']); } + /** + * Bandwidth is never claimed. + * + * @return void + */ public function testBandwidthIsNeverClaimed(): void { - $this->assertNull($this->service(available: true)->measure($this->organisation(requestQuota: 100))['bandwidth']); + $this->assertNull(actual: $this->service(available: true)->measure($this->organisation(requestQuota: 100))['bandwidth']); } } From e1628c094b36d2027186bec37302423d74885fdb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 17 Oct 2026 09:35:27 +0200 Subject: [PATCH 3/4] test(organisations): clear the new phpcs findings and tag the usage helpers Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/OrganisationUsageService.php | 8 ++++++++ tests/Service/ControllersIntegrationTest2.php | 3 ++- .../Controller/OrganisationControllerTest.php | 19 ++++++++++++++----- .../OrganisationGrantManageGuardTest.php | 4 ++-- tests/Unit/Db/OrganisationTest.php | 6 +++--- .../ActiveOrganisationManagementTest.php | 2 +- .../Service/EdgeCasesErrorHandlingTest.php | 2 +- tests/Unit/Service/OrganisationCrudTest.php | 2 +- .../UserOrganisationRelationshipTest.php | 2 +- 9 files changed, 33 insertions(+), 15 deletions(-) diff --git a/lib/Service/OrganisationUsageService.php b/lib/Service/OrganisationUsageService.php index f429fc372b..3bf136bb6f 100644 --- a/lib/Service/OrganisationUsageService.php +++ b/lib/Service/OrganisationUsageService.php @@ -87,6 +87,8 @@ public function measure(Organisation $organisation): array { * @param Organisation $organisation The organisation. * * @return int|null The bytes, or null when they could not be read. + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md */ private function measureStorage(Organisation $organisation): ?int { $uuid = $organisation->getUuid(); @@ -112,6 +114,8 @@ private function measureStorage(Organisation $organisation): ?int { * @param Organisation $organisation The organisation. * * @return int|null The count, or null when nothing counts them. + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md */ private function measureRequests(Organisation $organisation): ?int { $uuid = $organisation->getUuid(); @@ -129,6 +133,8 @@ private function measureRequests(Organisation $organisation): ?int { * Whether APCu is enabled. * * @return bool True when APCu can be read. + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md */ protected function apcuAvailable(): bool { return function_exists('apcu_enabled') === true && apcu_enabled() === true; @@ -140,6 +146,8 @@ protected function apcuAvailable(): bool { * @param string $key The key. * * @return int|null The value, or null when the key is not set. + * + * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md */ protected function apcuFetchInt(string $key): ?int { $success = false; diff --git a/tests/Service/ControllersIntegrationTest2.php b/tests/Service/ControllersIntegrationTest2.php index a136ae672e..8f89ed6697 100644 --- a/tests/Service/ControllersIntegrationTest2.php +++ b/tests/Service/ControllersIntegrationTest2.php @@ -1861,8 +1861,9 @@ private function buildOrganisationController(): OrganisationController { \OC::$server->get(TenantLifecycleService::class), \OC::$server->get(TenantUsageMapper::class), \OC::$server->get(IUserSession::class), + // phpcs:ignore CustomSniffs.Nextcloud.NoServiceLocator.GlobalContainerLookup -- this helper wires the real container services, as every argument above does. \OC::$server->get(IGroupManager::class), - \OC::$server->get(\OCA\OpenRegister\Service\OrganisationUsageService::class) + $this->createMock(originalClassName: \OCA\OpenRegister\Service\OrganisationUsageService::class) ); }//end buildOrganisationController() diff --git a/tests/Unit/Controller/OrganisationControllerTest.php b/tests/Unit/Controller/OrganisationControllerTest.php index b616dbd34b..e8fc5b3d96 100644 --- a/tests/Unit/Controller/OrganisationControllerTest.php +++ b/tests/Unit/Controller/OrganisationControllerTest.php @@ -40,6 +40,11 @@ class OrganisationControllerTest extends TestCase { private LoggerInterface&MockObject $logger; private IUserSession&MockObject $userSession; private IGroupManager&MockObject $groupManager; + /** + * The usage service double. + * + * @var OrganisationUsageService&MockObject + */ private OrganisationUsageService&MockObject $usageService; protected function setUp(): void { @@ -51,7 +56,7 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->userSession = $this->createMock(IUserSession::class); $this->groupManager = $this->createMock(IGroupManager::class); - $this->usageService = $this->createMock(OrganisationUsageService::class); + $this->usageService = $this->createMock(originalClassName: OrganisationUsageService::class); $this->controller = new OrganisationController( 'openregister', @@ -488,10 +493,12 @@ public function testLeaveExceptionWithUserId(): void { /** * Show replaces the entity's unmeasured usage with the measured one. * + * @return void + * * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md */ public function testShowReturnsMeasuredUsage(): void { - $org = $this->createMock(Organisation::class); + $org = $this->createMock(originalClassName: Organisation::class); $org->method('jsonSerialize')->willReturn(['uuid' => 'uuid-1', 'usage' => ['storage' => null, 'requests' => null, 'users' => 2]]); $measured = ['storage' => 4096, 'bandwidth' => null, 'requests' => null, 'users' => 2, 'groups' => 0]; $this->usageService->expects($this->once())->method('measure')->with($org)->willReturn($measured); @@ -502,19 +509,21 @@ public function testShowReturnsMeasuredUsage(): void { $data = $this->controller->show('uuid-1')->getData(); - $this->assertSame($measured, $data['organisation']['usage']); + $this->assertSame(expected: $measured, actual: $data['organisation']['usage']); } /** * A refused organisation is not measured. * + * @return void + * * @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md */ public function testShowDoesNotMeasureWhenNoAccess(): void { $this->organisationService->method('hasAccessToOrganisation')->willReturn(false); $this->usageService->expects($this->never())->method('measure'); - $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller->show('uuid-1')->getStatus()); + $this->assertSame(expected: Http::STATUS_FORBIDDEN, actual: $this->controller->show('uuid-1')->getStatus()); } public function testShowReturnsForbiddenWhenNoAccess(): void { @@ -2220,7 +2229,7 @@ private function controllerWithRealLifecycle(): OrganisationController { $this->createMock(TenantUsageMapper::class), $this->userSession, $this->groupManager, - $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) + $this->createMock(originalClassName: \OCA\OpenRegister\Service\OrganisationUsageService::class) ); } diff --git a/tests/Unit/Controller/OrganisationGrantManageGuardTest.php b/tests/Unit/Controller/OrganisationGrantManageGuardTest.php index c6a6562cd1..cfb5de0749 100644 --- a/tests/Unit/Controller/OrganisationGrantManageGuardTest.php +++ b/tests/Unit/Controller/OrganisationGrantManageGuardTest.php @@ -77,8 +77,8 @@ protected function setUp(): void { $this->createMock(TenantLifecycleService::class), $this->createMock(TenantUsageMapper::class), $this->createMock(IUserSession::class), - $this->createMock(IGroupManager::class), - $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) + $this->createMock(originalClassName: IGroupManager::class), + $this->createMock(originalClassName: \OCA\OpenRegister\Service\OrganisationUsageService::class) ); }//end setUp() diff --git a/tests/Unit/Db/OrganisationTest.php b/tests/Unit/Db/OrganisationTest.php index 5793ac178f..2f485b66a4 100644 --- a/tests/Unit/Db/OrganisationTest.php +++ b/tests/Unit/Db/OrganisationTest.php @@ -553,9 +553,9 @@ public function testJsonSerializeUsageStructure(): void { $json = $this->organisation->jsonSerialize(); $usage = $json['usage']; - $this->assertNull($usage['storage']); - $this->assertNull($usage['bandwidth']); - $this->assertNull($usage['requests']); + $this->assertNull(actual: $usage['storage']); + $this->assertNull(actual: $usage['bandwidth']); + $this->assertNull(actual: $usage['requests']); $this->assertSame(2, $usage['users']); $this->assertSame(3, $usage['groups']); } diff --git a/tests/Unit/Service/ActiveOrganisationManagementTest.php b/tests/Unit/Service/ActiveOrganisationManagementTest.php index 6ae5bad039..dbd7ea8408 100644 --- a/tests/Unit/Service/ActiveOrganisationManagementTest.php +++ b/tests/Unit/Service/ActiveOrganisationManagementTest.php @@ -181,7 +181,7 @@ protected function setUp(): void { tenantUsageMapper: $this->createMock(TenantUsageMapper::class), userSession: $this->userSession, groupManager: $this->groupManager, - usageService: $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) + usageService: $this->createMock(originalClassName: \OCA\OpenRegister\Service\OrganisationUsageService::class) ); } diff --git a/tests/Unit/Service/EdgeCasesErrorHandlingTest.php b/tests/Unit/Service/EdgeCasesErrorHandlingTest.php index b57d3a9153..3b35636551 100644 --- a/tests/Unit/Service/EdgeCasesErrorHandlingTest.php +++ b/tests/Unit/Service/EdgeCasesErrorHandlingTest.php @@ -103,7 +103,7 @@ protected function setUp(): void { $this->createMock(TenantUsageMapper::class), $this->userSession, $this->groupManager, - $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) + $this->createMock(originalClassName: \OCA\OpenRegister\Service\OrganisationUsageService::class) ); } diff --git a/tests/Unit/Service/OrganisationCrudTest.php b/tests/Unit/Service/OrganisationCrudTest.php index c557cb2cfe..2631bc7840 100644 --- a/tests/Unit/Service/OrganisationCrudTest.php +++ b/tests/Unit/Service/OrganisationCrudTest.php @@ -182,7 +182,7 @@ protected function setUp(): void { tenantUsageMapper: $this->createMock(TenantUsageMapper::class), userSession: $this->userSession, groupManager: $this->groupManager, - usageService: $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) + usageService: $this->createMock(originalClassName: \OCA\OpenRegister\Service\OrganisationUsageService::class) ); } diff --git a/tests/Unit/Service/UserOrganisationRelationshipTest.php b/tests/Unit/Service/UserOrganisationRelationshipTest.php index 6cbcd6ad09..f846ea89b6 100644 --- a/tests/Unit/Service/UserOrganisationRelationshipTest.php +++ b/tests/Unit/Service/UserOrganisationRelationshipTest.php @@ -181,7 +181,7 @@ protected function setUp(): void { tenantUsageMapper: $this->createMock(TenantUsageMapper::class), userSession: $this->userSession, groupManager: $this->groupManager, - usageService: $this->createMock(\OCA\OpenRegister\Service\OrganisationUsageService::class) + usageService: $this->createMock(originalClassName: \OCA\OpenRegister\Service\OrganisationUsageService::class) ); } From 1700c01105d8b2fe2e3b981e533b5a0329b85d8e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 17 Oct 2026 09:53:57 +0200 Subject: [PATCH 4/4] refactor(organisations): note why the controller constructor takes ten collaborators Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Controller/OrganisationController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Controller/OrganisationController.php b/lib/Controller/OrganisationController.php index 370eb4c94d..d4274f1ebf 100644 --- a/lib/Controller/OrganisationController.php +++ b/lib/Controller/OrganisationController.php @@ -130,6 +130,8 @@ class OrganisationController extends Controller { * @param IGroupManager $groupManager Group manager for admin checks * @param OrganisationUsageService $usageService Measures an organisation's usage * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) DI constructor: each argument is a collaborator an endpoint uses. + * * @spec openspec/specs/tenant-lifecycle/spec.md */ public function __construct(